From a11206f5f1352c583c90e8e76421b3cdfe91767e Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 25 Oct 2026 07:25:33 +0000 Subject: [PATCH 1/8] feat(arc): expand a repository-scoped github_runner_arc_orgs entry An entry with repository registers its scale sets in name/repository, where name may be a user or an organisation, so a repository of a personal account can use the fleet. Each profile now records its scope, repository and githubConfigUrl. Names derived for a repository scope join the login and the repository with a double hyphen, which no GitHub login contains, so they never collide with the organisation's own or another repository's. The duplicate, release and cross-cluster checks key on the scope instead of the login, and arc_scope gives the tasks the same key. --- plugins/filter/arc.py | 96 ++++++++++++++++++++++++---------- tests/unit/test_arc_filters.py | 81 +++++++++++++++++++++++++++- 2 files changed, 147 insertions(+), 30 deletions(-) diff --git a/plugins/filter/arc.py b/plugins/filter/arc.py index 5ea3009..6f1e215 100644 --- a/plugins/filter/arc.py +++ b/plugins/filter/arc.py @@ -15,6 +15,13 @@ _DNS_LABEL = re.compile(r"^[a-z0-9]([-a-z0-9]*[a-z0-9])?$") _DNS_LABEL_MAX_LENGTH = 63 +# Where a scale set registers: githubConfigUrl is this followed by the entry's scope, an account login (an organisation's runners) or login/repository (one repository's runners), as the gha-runner-scale-set chart's values.yaml documents it. +_GITHUB_URL = "https://github.com/" +# Joins an entry's login and repository in the names derived from a repository scope. GitHub refuses consecutive hyphens in a login, so a login-only (organisation) scope never contains it, and the names of an organisation scope and a repository scope of the same login always differ. +_REPOSITORY_NAME_SEPARATOR = "--" +# A repository name may hold '.', '_' and upper-case letters, which no DNS label does; each such character becomes '-' in derived names. Two repositories that then derive the same name are refused by arc_profiles' namespace and release checks, and a profile's namespace and release_name override them. +_NOT_IN_DNS_LABEL = re.compile(r"[^a-z0-9-]") + # Sizing inputs: (key, required, default, lower bound, lower bound inclusive, upper bound, upper bound inclusive). None means unbounded. _SIZING_INPUTS: tuple[tuple[str, bool, str, str | None, bool, str | None, bool], ...] = ( ("node_allocatable_cpu", True, "0", "0", False, None, False), @@ -538,14 +545,32 @@ def arc_runner_placement(profile: Mapping[str, Any], eligibility: Mapping[str, A return {"nodeSelector": dict(profile["node_selector"]), "affinity": {"nodeAffinity": node_affinity}, "tolerations": own_tolerations + burst["tolerations"]} -def _expand_profile(org_name: str, runner_image: 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}]" +def arc_scope(org: Mapping[str, Any]) -> str: + """Return the scope a github_runner_arc_orgs entry registers its scale sets in: its name (an organisation) or, with repository set, name/repository (that one repository, of a user or an organisation). + + The scope identifies an entry: it is what githubConfigUrl names, and the key the role records an entry's installation id and pull token under, so two entries of one login (the organisation and one of its repositories, or two repositories) stay apart. + """ + name = _text(org, "name") + repository = _text(org, "repository") + return f"{name}/{repository}" if repository else name + + +def _scope_slug(org_name: str, repository: str) -> str: + """Return the stem of the names derived for an entry's scope: the lower-cased login, joined to the repository with _REPOSITORY_NAME_SEPARATOR when there is one.""" + if not repository: + return org_name.lower() + return f"{org_name.lower()}{_REPOSITORY_NAME_SEPARATOR}{_NOT_IN_DNS_LABEL.sub('-', repository.lower())}" + + +def _expand_profile(org_name: str, repository: str, runner_image: str, app_secret: str, index: int, profile: Any, errors: list[str]) -> dict[str, Any] | None: + """Describe one scale-set profile of an entry, or record why it cannot be described.""" + scope = f"{org_name}/{repository}" if repository else org_name + where = f"{scope} scale_set_profiles[{index}]" if not isinstance(profile, Mapping): errors.append(f"{where} must be a mapping") return None suffix = _text(profile, "suffix") - org_lower = org_name.lower() + slug = _scope_slug(org_name, repository) node_selector = profile.get("node_selector") or {} if not isinstance(node_selector, Mapping): errors.append(f"{where}.node_selector must be a mapping") @@ -581,10 +606,13 @@ def _expand_profile(org_name: str, runner_image: str, app_secret: str, index: in max_runners = None if sizing_result["max_runners"] is None else sizing_result["max_runners"] + burst_runners else: max_runners = None - 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}" + namespace = _optional_name(profile, "namespace", where, errors) or f"arc-runners-{slug}{suffix}" + release = _optional_name(profile, "release_name", where, errors) or f"{slug}-runners{suffix}" expanded = { "org_name": org_name, + "repository": repository, + "scope": scope, + "config_url": f"{_GITHUB_URL}{scope}", "suffix": suffix, "namespace": namespace, "release": release, @@ -592,7 +620,7 @@ def _expand_profile(org_name: str, runner_image: str, app_secret: str, index: in "target": f"{namespace}/{release}", "app_secret": app_secret, "runner_image": runner_image, - "scale_set_labels": _scale_set_labels(profile, f"{org_lower}-runners", where, errors), + "scale_set_labels": _scale_set_labels(profile, f"{slug}-runners", where, errors), "values_file": _text(profile, "values_file"), "node_selector": dict(node_selector), "autoscale": autoscale, @@ -603,7 +631,7 @@ def _expand_profile(org_name: str, runner_image: str, app_secret: str, index: in "max_runners": max_runners, "capabilities": _capability_names(profile, where, errors), "capability_store": capability_store, - "label": f"{org_name}{suffix}", + "label": f"{scope}{suffix}", "settings": dict(profile), } # The role's own values template needs a static maxRunners: max_runners, or sizing on a profile the autoscaler does not manage (the autoscaled profile's sizing sets the autoscaler's ceiling, not its floor). @@ -617,14 +645,16 @@ def _expand_profile(org_name: str, runner_image: str, app_secret: str, index: in def arc_profiles(orgs: Sequence[Any], require_app_id: bool = True) -> dict[str, Any]: """Expand github_runner_arc_orgs entries into one record per scale-set profile, and check them. - Pass every org that installs into one cluster, not one host's, so that the cross-host checks (an org configured twice, two profiles sharing a namespace, more than one autoscaled profile setting its own sizing) see the whole cluster; arc_cluster_profiles gathers them from the inventory. + Pass every entry that installs into one cluster, not one host's, so that the cross-host checks (a scope configured twice, two profiles sharing a namespace, more than one autoscaled profile setting its own sizing) see the whole cluster; arc_cluster_profiles gathers them from the inventory. + + An entry without repository registers organisation runners in the organisation name names. An entry with repository registers repository runners in name/repository instead, where name may be a user or an organisation; its derived namespace, release, scaleSetLabels default and App Secret name join name and repository with _REPOSITORY_NAME_SEPARATOR, so they differ from the organisation's and from every other repository's. The scope (see arc_scope) identifies an entry: the same scope twice is an error, while one login's organisation entry and its repository entries, or several repository entries, may sit side by side. Args: 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. - 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. A profile may set capability_store (a boolean, default false) to copy its tool-cache and system-library payloads, and the dind sidecar's externals, into the capability store, which the pods of a node share, instead of into every pod (see arc_capability_pod). A profile with sizing may set burst (node_label_key, node_label_values, tolerations, max_runners) to let its runner pods overflow onto burst nodes, and a profile without sizing may set burst with exclusive true to run on burst nodes only, with burst.max_runners as its maxRunners; see arc_runner_placement. + 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 and repository (a repository name in name's account, without the owner). A profile may override its namespace and release_name, and set scale_set_labels in place of runs_on_label. A profile may set capability_store (a boolean, default false) to copy its tool-cache and system-library payloads, and the dind sidecar's externals, into the capability store, which the pods of a node share, instead of into every pod (see arc_capability_pod). A profile with sizing may set burst (node_label_key, node_label_values, tolerations, max_runners) to let its runner pods overflow onto burst nodes, and a profile without sizing may set burst with exclusive true to run on burst nodes only, with burst.max_runners as its maxRunners; see arc_runner_placement. Returns: - A dict with ``errors`` (messages, empty when valid), ``profiles`` (one dict per profile with org_name, suffix, namespace, release, target (namespace/release, joined), app_secret, runner_image (its org's image), scale_set_labels, values_file, node_selector, autoscale, sizing, burst (the burst settings with defaults filled in, or None), burst_runners (burst.max_runners, 0 without burst), max_runners (the static maxRunners the role sets, including burst_runners, or None), capabilities (the names of the capabilities its runner pods carry, in order; see arc_capability_pod), capability_store (whether it uses the capability store), label and settings, the profile's own keys) and ``autoscaled`` (every profile flagged autoscale, sharing one autoscaler-managed memory pool across their combined scale sets; empty when none are). + A dict with ``errors`` (messages, empty when valid), ``profiles`` (one dict per profile with org_name (the entry's name), repository (empty for an organisation scope), scope (see arc_scope), config_url (the githubConfigUrl it registers with), suffix, namespace, release, target (namespace/release, joined), app_secret, runner_image (its org's image), scale_set_labels, values_file, node_selector, autoscale, sizing, burst (the burst settings with defaults filled in, or None), burst_runners (burst.max_runners, 0 without burst), max_runners (the static maxRunners the role sets, including burst_runners, or None), capabilities (the names of the capabilities its runner pods carry, in order; see arc_capability_pod), capability_store (whether it uses the capability store), label and settings, the profile's own keys) and ``autoscaled`` (every profile flagged autoscale, sharing one autoscaler-managed memory pool across their combined scale sets; empty when none are). """ errors: list[str] = [] profiles: list[dict[str, Any]] = [] @@ -640,24 +670,33 @@ def arc_profiles(orgs: Sequence[Any], require_app_id: bool = True) -> dict[str, if not org_name: errors.append(f"{where} has no name") continue + if "/" in org_name: + errors.append(f"{where}.name must be an account login, without '/'; set repository for a repository's runners") + continue + repository = org.get("repository") + if repository is not None and (not isinstance(repository, str) or not repository or "/" in repository): + errors.append(f"{where}.repository must be a repository name without its owner (no '/') when set") + continue + repository = repository or "" + scope = arc_scope(org) if require_app_id and not _text(org, "app_id"): - 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") + errors.append(f"{scope}: 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") if not _text(org, "image"): - errors.append(f"{org_name}: image is required") + errors.append(f"{scope}: image is required") # Presence only: reading the value would decrypt an ansible-vault string for no reason. if org.get("private_key") is not None and org.get("private_key_op_reference") is not None: - errors.append(f"{org_name}: set private_key or private_key_op_reference, not both (the 1Password adapter fills private_key from the reference)") - if org_name.lower() in seen_orgs: - errors.append(f"Org '{org_name}' is configured more than once in github_runner_arc_orgs (also as '{seen_orgs[org_name.lower()]}'). Each org's Helm releases in a cluster must be installed from one entry on one host; remove the duplicate.") + errors.append(f"{scope}: set private_key or private_key_op_reference, not both (the 1Password adapter fills private_key from the reference)") + if scope.lower() in seen_orgs: + errors.append(f"'{scope}' is configured more than once in github_runner_arc_orgs (also as '{seen_orgs[scope.lower()]}'). Each scope's Helm releases in a cluster must be installed from one entry on one host; remove the duplicate.") continue - seen_orgs[org_name.lower()] = org_name + seen_orgs[scope.lower()] = scope org_profiles = org.get("scale_set_profiles") 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") + errors.append(f"{scope}: 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" + app_secret = _optional_name(org, "app_secret_name", scope, errors) or f"{_scope_slug(org_name, repository)}-github-app" for profile_index, profile in enumerate(org_profiles): - expanded = _expand_profile(org_name, _text(org, "image"), app_secret, profile_index, profile, errors) + expanded = _expand_profile(org_name, repository, _text(org, "image"), app_secret, profile_index, profile, errors) if expanded is not None: profiles.append(expanded) namespaces: dict[str, str] = {} @@ -666,8 +705,8 @@ def arc_profiles(orgs: Sequence[Any], require_app_id: bool = True) -> dict[str, 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 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"]) + # The release name is also the scale set's registration name on GitHub, so two releases of one scope sharing it would register a duplicate even from different namespaces. Scopes register apart, so another scope's release of the same name is no conflict. + release_key = (profile["scope"].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"]) @@ -706,11 +745,11 @@ def arc_cluster_profiles(orgs_by_host: Mapping[str, Any], cluster: Sequence[str] errors.append(f"{host}: github_runner_arc_orgs must be a list") continue for org in orgs: - name = _text(org, "name") if isinstance(org, Mapping) else "" + name = arc_scope(org) if isinstance(org, Mapping) else "" installer = installers.get(name.lower()) if name else None - # The same org twice on one host is arc_profiles' own check, so it is passed on for that. + # The same scope twice on one host is arc_profiles' own check, so it is passed on for that. if installer is not None and installer[0] != host: - errors.append(f"Org '{name}' is configured more than once in github_runner_arc_orgs for one cluster, on {installer[0]} (as '{installer[1]}') and on {host}. Each org's Helm releases in a cluster must be installed from one host; move its scale_set_profiles onto one of them.") + errors.append(f"'{name}' is configured more than once in github_runner_arc_orgs for one cluster, on {installer[0]} (as '{installer[1]}') and on {host}. Each scope's Helm releases in a cluster must be installed from one host; move its scale_set_profiles onto one of them.") continue if name: installers.setdefault(name.lower(), (host, name)) @@ -724,13 +763,13 @@ def arc_cluster_profiles(orgs_by_host: Mapping[str, Any], cluster: Sequence[str] continue for profile in arc_profiles(orgs, require_app_id=False)["profiles"]: if host in members: - registered.setdefault((profile["org_name"].lower(), profile["release"]), (host, profile["label"])) + registered.setdefault((profile["scope"].lower(), profile["release"]), (host, profile["label"])) else: elsewhere.append((host, profile)) for host, profile in elsewhere: - match = registered.get((profile["org_name"].lower(), profile["release"])) + match = registered.get((profile["scope"].lower(), profile["release"])) if match is not None: - errors.append(f"{match[1]} on {match[0]} and {profile['label']} on {host}, which installs into another cluster, both register scale set '{profile['release']}' for org '{profile['org_name']}'. GitHub gives a scale set name to one controller at a time; uninstall it from one cluster before installing it in the other, or give one of them a different suffix or release_name.") + errors.append(f"{match[1]} on {match[0]} and {profile['label']} on {host}, which installs into another cluster, both register scale set '{profile['release']}' in '{profile['scope']}'. GitHub gives a scale set name to one controller at a time; uninstall it from one cluster before installing it in the other, or give one of them a different suffix or release_name.") return {**result, "errors": errors + result["errors"]} @@ -1920,6 +1959,7 @@ def filters(self) -> dict[str, Any]: """Return the filters this plugin provides.""" return { "arc_profiles": arc_profiles, + "arc_scope": arc_scope, "arc_cluster_profiles": arc_cluster_profiles, "arc_max_runners": arc_max_runners, "arc_measured_max_runners": arc_measured_max_runners, diff --git a/tests/unit/test_arc_filters.py b/tests/unit/test_arc_filters.py index cf1827c..7a1f1b6 100644 --- a/tests/unit/test_arc_filters.py +++ b/tests/unit/test_arc_filters.py @@ -188,7 +188,7 @@ def test_only_the_clusters_own_hosts_are_expanded(self) -> None: def test_an_org_on_two_hosts_of_one_cluster_names_both_hosts(self) -> None: result = arc.arc_cluster_profiles(self.SPLIT, ["node-a", "node-b"]) - self.assertEqual(result["errors"], ["Org 'Example' is configured more than once in github_runner_arc_orgs for one cluster, on node-a (as 'Example') and on node-b. Each org's Helm releases in a cluster must be installed from one host; move its scale_set_profiles onto one of them."]) + self.assertEqual(result["errors"], ["'Example' is configured more than once in github_runner_arc_orgs for one cluster, on node-a (as 'Example') and on node-b. Each scope's Helm releases in a cluster must be installed from one host; move its scale_set_profiles onto one of them."]) def test_an_unknown_cluster_counts_every_host_as_one(self) -> None: result = arc.arc_cluster_profiles(self.SPLIT) @@ -202,7 +202,7 @@ def test_the_same_org_twice_on_one_host_is_still_an_error(self) -> None: def test_one_scale_set_registered_from_two_clusters_is_an_error(self) -> None: result = arc.arc_cluster_profiles({"node-a": [org(profiles=[{"max_runners": 1}])], "node-b": [org("example", [{"namespace": "elsewhere", "max_runners": 1}])]}, ["node-a"]) - self.assertEqual(result["errors"], ["Example on node-a and example on node-b, which installs into another cluster, both register scale set 'example-runners' for org 'example'. GitHub gives a scale set name to one controller at a time; uninstall it from one cluster before installing it in the other, or give one of them a different suffix or release_name."]) + self.assertEqual(result["errors"], ["Example on node-a and example on node-b, which installs into another cluster, both register scale set 'example-runners' in 'example'. GitHub gives a scale set name to one controller at a time; uninstall it from one cluster before installing it in the other, or give one of them a different suffix or release_name."]) def test_another_orgs_scale_set_of_the_same_name_is_not_a_conflict(self) -> None: result = arc.arc_cluster_profiles({"node-a": [org(profiles=[{"release_name": "ci", "max_runners": 1}])], "node-b": [org("Other", [{"release_name": "ci", "max_runners": 1}])]}, ["node-a"]) @@ -222,6 +222,83 @@ def test_a_hosts_orgs_must_be_a_list(self) -> None: self.assertEqual(result["errors"], ["node-a: github_runner_arc_orgs must be a list"]) +class RepositoryScopeTest(unittest.TestCase): + def test_an_entry_without_repository_registers_in_the_organisation(self) -> None: + profile = arc.arc_profiles([org("Example")])["profiles"][0] + self.assertEqual((profile["scope"], profile["repository"], profile["config_url"], profile["label"]), ("Example", "", "https://github.com/Example", "Example")) + + def test_an_entry_with_repository_registers_in_that_repository(self) -> None: + result = arc.arc_profiles([org("example-user", [{"max_runners": 1}, {"suffix": "-big", "max_runners": 1}], repository="example-repo")]) + self.assertEqual(result["errors"], []) + default, big = result["profiles"] + self.assertEqual((default["org_name"], default["repository"], default["scope"], default["config_url"]), ("example-user", "example-repo", "example-user/example-repo", "https://github.com/example-user/example-repo")) + self.assertEqual((default["namespace"], default["release"], default["app_secret"], default["scale_set_labels"], default["label"]), ("arc-runners-example-user--example-repo", "example-user--example-repo-runners", "example-user--example-repo-github-app", ["example-user--example-repo-runners"], "example-user/example-repo")) + self.assertEqual((big["namespace"], big["release"], big["label"]), ("arc-runners-example-user--example-repo-big", "example-user--example-repo-runners-big", "example-user/example-repo-big")) + + def test_an_organisation_and_its_repositories_derive_distinct_names(self) -> None: + result = arc.arc_profiles([org("example-org"), org("example-org", repository="first"), org("example-org", repository="second")]) + self.assertEqual(result["errors"], []) + for key in ("namespace", "release", "app_secret", "target", "scope", "config_url"): + values = [profile[key] for profile in result["profiles"]] + self.assertEqual(len(set(values)), 3, f"{key}: {values}") + + def test_a_repository_named_like_an_organisation_suffix_does_not_collide(self) -> None: + # The organisation's suffixed profile and the repository's default profile would both be example-org-builder without the separator. + result = arc.arc_profiles([org("example-org", [{"suffix": "-builder", "max_runners": 1}]), org("example-org", repository="builder")]) + self.assertEqual(result["errors"], []) + self.assertNotEqual(result["profiles"][0]["namespace"], result["profiles"][1]["namespace"]) + self.assertNotEqual(result["profiles"][0]["release"], result["profiles"][1]["release"]) + + def test_the_same_repository_twice_is_an_error_even_in_a_different_case(self) -> None: + result = arc.arc_profiles([org("example-user", repository="example-repo"), org("Example-User", repository="Example-Repo")]) + self.assertEqual(result["errors"], ["'Example-User/Example-Repo' is configured more than once in github_runner_arc_orgs (also as 'example-user/example-repo'). Each scope's Helm releases in a cluster must be installed from one entry on one host; remove the duplicate."]) + + def test_repository_characters_no_dns_label_allows_become_hyphens(self) -> None: + profile = arc.arc_profiles([org("example-user", repository="Example.Repo_2")])["profiles"][0] + self.assertEqual((profile["namespace"], profile["release"], profile["config_url"]), ("arc-runners-example-user--example-repo-2", "example-user--example-repo-2-runners", "https://github.com/example-user/Example.Repo_2")) + + def test_repositories_that_derive_the_same_names_are_refused(self) -> None: + result = arc.arc_profiles([org("example-user", repository="example.repo"), org("example-user", repository="example_repo")]) + self.assertTrue(any("both map to namespace 'arc-runners-example-user--example-repo'" in message for message in result["errors"]), result["errors"]) + + def test_a_repository_must_be_a_bare_repository_name(self) -> None: + for repository in ("", "example-user/example-repo", 5): + with self.subTest(repository=repository): + result = arc.arc_profiles([org("example-user", repository=repository)]) + self.assertEqual(result["errors"], ["github_runner_arc_orgs[0].repository must be a repository name without its owner (no '/') when set"]) + self.assertEqual(result["profiles"], []) + + def test_a_name_holding_a_repository_is_refused(self) -> None: + result = arc.arc_profiles([org("example-user/example-repo")]) + self.assertEqual(result["errors"], ["github_runner_arc_orgs[0].name must be an account login, without '/'; set repository for a repository's runners"]) + + def test_an_unset_or_null_repository_is_the_organisation_scope(self) -> None: + self.assertEqual(arc.arc_scope(org("Example")), "Example") + self.assertEqual(arc.arc_scope(org("Example", repository=None)), "Example") + self.assertEqual(arc.arc_scope(org("example-user", repository="example-repo")), "example-user/example-repo") + + def test_a_release_name_may_repeat_across_scopes_of_one_login(self) -> None: + result = arc.arc_profiles([org("example-org", [{"release_name": "ci", "namespace": "ci-org", "max_runners": 1}]), org("example-org", [{"release_name": "ci", "namespace": "ci-repo", "max_runners": 1}], repository="example-repo")]) + self.assertEqual(result["errors"], []) + + def test_the_same_repository_on_two_hosts_of_one_cluster_is_an_error(self) -> None: + result = arc.arc_cluster_profiles({"node-a": [org("example-user", repository="example-repo")], "node-b": [org("example-user", [{"suffix": "-big", "max_runners": 1}], repository="example-repo")]}, ["node-a", "node-b"]) + self.assertEqual(result["errors"], ["'example-user/example-repo' is configured more than once in github_runner_arc_orgs for one cluster, on node-a (as 'example-user/example-repo') and on node-b. Each scope's Helm releases in a cluster must be installed from one host; move its scale_set_profiles onto one of them."]) + + def test_an_organisation_and_its_repository_may_be_installed_from_two_hosts(self) -> None: + result = arc.arc_cluster_profiles({"node-a": [org("example-org")], "node-b": [org("example-org", repository="example-repo")]}, ["node-a", "node-b"]) + self.assertEqual(result["errors"], []) + + def test_one_repository_scale_set_registered_from_two_clusters_is_an_error(self) -> None: + result = arc.arc_cluster_profiles({"node-a": [org("example-user", repository="example-repo")], "node-b": [org("example-user", [{"namespace": "elsewhere", "max_runners": 1}], repository="example-repo")]}, ["node-a"]) + self.assertEqual(len(result["errors"]), 1) + self.assertIn("both register scale set 'example-user--example-repo-runners' in 'example-user/example-repo'", result["errors"][0]) + + def test_an_organisation_scale_set_and_a_repository_scale_set_of_one_name_do_not_conflict_across_clusters(self) -> None: + result = arc.arc_cluster_profiles({"node-a": [org("example-org", [{"release_name": "ci", "max_runners": 1}])], "node-b": [org("example-org", [{"release_name": "ci", "max_runners": 1}], repository="example-repo")]}, ["node-a"]) + self.assertEqual(result["errors"], []) + + class MaxRunnersTest(unittest.TestCase): def test_the_tighter_resource_bounds_pods_per_node(self) -> None: # CPU allows 8 / 1 = 8 per node; memory allows 16 / 3 = 5, so memory is the bound. From 3c35c51483f547aa69be5d018cbd0f90f6cea052 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 25 Oct 2026 07:37:25 +0000 Subject: [PATCH 2/8] fix(arc): refuse a derived release name longer than Helm accepts Helm refuses a release name over 53 characters, so a name between that and the DNS label limit passed validation and failed only at helm upgrade. Release names are now checked against Helm's own limit. --- plugins/filter/arc.py | 12 +++++++----- tests/unit/test_arc_filters.py | 13 +++++++++++++ 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/plugins/filter/arc.py b/plugins/filter/arc.py index 6f1e215..9467f81 100644 --- a/plugins/filter/arc.py +++ b/plugins/filter/arc.py @@ -14,6 +14,8 @@ # Kubernetes namespace and Helm release names must be RFC 1123 DNS labels. _DNS_LABEL = re.compile(r"^[a-z0-9]([-a-z0-9]*[a-z0-9])?$") _DNS_LABEL_MAX_LENGTH = 63 +# Helm refuses a longer release name (maxReleaseNameLen in Helm's pkg/chart/v2/util/validate_name.go), leaving room under the DNS label limit for the suffixes charts add to it. +_HELM_RELEASE_NAME_MAX_LENGTH = 53 # Where a scale set registers: githubConfigUrl is this followed by the entry's scope, an account login (an organisation's runners) or login/repository (one repository's runners), as the gha-runner-scale-set chart's values.yaml documents it. _GITHUB_URL = "https://github.com/" @@ -427,10 +429,10 @@ def _text(mapping: Mapping[str, Any], key: str) -> str: return "" if value is None else str(value) -def _check_dns_label(kind: str, name: str, where: str, errors: list[str]) -> None: - """Record an error when name is not a valid Kubernetes DNS label.""" - if len(name) > _DNS_LABEL_MAX_LENGTH or not _DNS_LABEL.match(name): - 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 _check_dns_label(kind: str, name: str, where: str, errors: list[str], max_length: int = _DNS_LABEL_MAX_LENGTH) -> None: + """Record an error when name is not a valid Kubernetes DNS label of at most max_length characters.""" + if len(name) > max_length or not _DNS_LABEL.match(name): + errors.append(f"{where}: the derived {kind} '{name}' is not a valid Kubernetes name (lower-case letters, digits and '-', at most {max_length} characters)") def _optional_name(mapping: Mapping[str, Any], key: str, where: str, errors: list[str]) -> str: @@ -638,7 +640,7 @@ def _expand_profile(org_name: str, repository: str, runner_image: str, app_secre if not expanded["values_file"] and profile.get("max_runners") is None and (sizing_result is None or autoscale): errors.append(f"{where}: a profile with no values_file needs max_runners{'' if autoscale else ' or sizing'}") _check_dns_label("namespace", expanded["namespace"], where, errors) - _check_dns_label("release name", expanded["release"], where, errors) + _check_dns_label("release name", expanded["release"], where, errors, _HELM_RELEASE_NAME_MAX_LENGTH) return expanded diff --git a/tests/unit/test_arc_filters.py b/tests/unit/test_arc_filters.py index 7a1f1b6..e823719 100644 --- a/tests/unit/test_arc_filters.py +++ b/tests/unit/test_arc_filters.py @@ -268,6 +268,19 @@ def test_a_repository_must_be_a_bare_repository_name(self) -> None: self.assertEqual(result["errors"], ["github_runner_arc_orgs[0].repository must be a repository name without its owner (no '/') when set"]) self.assertEqual(result["profiles"], []) + def test_a_release_name_longer_than_helm_accepts_is_refused(self) -> None: + # The DNS label limit would accept this release name; Helm's does not. + repository = "r" * 35 + result = arc.arc_profiles([org("example-user", repository=repository)]) + release = f"example-user--{repository}-runners" + self.assertGreater(len(release), 53) + self.assertLessEqual(len(release), 63) + self.assertEqual(result["errors"], [f"example-user/{repository} scale_set_profiles[0]: the derived release name '{release}' is not a valid Kubernetes name (lower-case letters, digits and '-', at most 53 characters)"]) + + def test_a_release_name_of_helms_maximum_is_accepted(self) -> None: + result = arc.arc_profiles([org("example-user", [{"release_name": "r" * 53, "max_runners": 1}], repository="example-repo")]) + self.assertEqual(result["errors"], []) + def test_a_name_holding_a_repository_is_refused(self) -> None: result = arc.arc_profiles([org("example-user/example-repo")]) self.assertEqual(result["errors"], ["github_runner_arc_orgs[0].name must be an account login, without '/'; set repository for a repository's runners"]) From fe308711b1d246f9378618ab8354fadadb546924 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 25 Oct 2026 07:39:16 +0000 Subject: [PATCH 3/8] fix(secrets): key App private keys read from 1Password by reference The adapter read one key per entry name, so two github_runner_arc_orgs entries of one login naming different keys (an organisation and one of its repositories) got the same key. Keys are now read, cached and filled by their 1Password reference. --- plugins/filter/onepassword.py | 11 +++++------ .../tasks/main.yml | 6 +++--- tests/unit/test_onepassword.py | 13 +++++++++---- 3 files changed, 17 insertions(+), 13 deletions(-) diff --git a/plugins/filter/onepassword.py b/plugins/filter/onepassword.py index a6b1751..826d1f0 100644 --- a/plugins/filter/onepassword.py +++ b/plugins/filter/onepassword.py @@ -11,7 +11,7 @@ def onepassword_fill_org_keys(orgs: Sequence[Mapping[str, Any]], org_private_keys: Mapping[str, str]) -> list[dict[str, Any]]: """Give every org that names its App private key by 1Password reference the key itself. - :param orgs: github_runner_arc_orgs entries. :param org_private_keys: the keys read from 1Password, by org name. :returns: the entries in the same order, each one with a private_key_op_reference replaced by private_key holding the key read for it, and every other entry unchanged. :raises AnsibleFilterError: if orgs is not a list, or no key was read for an org that has a reference. + :param orgs: github_runner_arc_orgs entries. :param org_private_keys: the keys read from 1Password, by 1Password reference, so entries of one login (an organisation and its repositories) that name different keys each get their own, and entries naming one reference share it. :returns: the entries in the same order, each one with a private_key_op_reference replaced by private_key holding the key read for it, and every other entry unchanged. :raises AnsibleFilterError: if orgs is not a list, or no key was read for an org that has a reference. """ if not isinstance(orgs, Sequence) or isinstance(orgs, (str, bytes)): raise AnsibleFilterError("github_runner_arc_orgs must be a list") @@ -19,11 +19,10 @@ def onepassword_fill_org_keys(orgs: Sequence[Mapping[str, Any]], org_private_key for org in orgs: entry = dict(org) if "private_key_op_reference" in entry: - name = entry.get("name") - if name not in org_private_keys: - raise AnsibleFilterError(f"No App private key was read from 1Password for org {name}") - del entry["private_key_op_reference"] - entry["private_key"] = org_private_keys[name] + reference = entry.pop("private_key_op_reference") + if reference not in org_private_keys: + raise AnsibleFilterError(f"No App private key was read from 1Password for {entry.get('name')} ({reference})") + entry["private_key"] = org_private_keys[reference] filled.append(entry) return filled diff --git a/roles/github_runner_secrets_onepassword/tasks/main.yml b/roles/github_runner_secrets_onepassword/tasks/main.yml index 1355510..873775f 100644 --- a/roles/github_runner_secrets_onepassword/tasks/main.yml +++ b/roles/github_runner_secrets_onepassword/tasks/main.yml @@ -30,7 +30,7 @@ | selectattr('github_runner_secrets_onepassword_item', 'defined') | selectattr('github_runner_secrets_onepassword_item', 'equalto', github_runner_secrets_onepassword_item) | map(attribute='github_runner_arc_orgs', default=[]) - | list | flatten | selectattr('private_key_op_reference', 'defined') | unique(attribute='name')) + | list | flatten | selectattr('private_key_op_reference', 'defined') | unique(attribute='private_key_op_reference')) if 'arc' in github_runner_secrets_onepassword_consumers else [] }} @@ -46,7 +46,7 @@ ansible.builtin.set_fact: github_runner_secrets_onepassword_orgs_to_read: >- {{ github_runner_secrets_onepassword_all_orgs - | rejectattr('name', 'in', (github_runner_secrets_onepassword_cached.org_private_keys | default({})).keys() | list) | list }} + | rejectattr('private_key_op_reference', 'in', (github_runner_secrets_onepassword_cached.org_private_keys | default({})).keys() | list) | list }} # A field missing from the item reads as empty rather than failing the read, so the item only needs the fields the play's roles use; the checks below and the roles' own checks name any that is missing. - name: Read the 1Password item and org keys this play needs in one shell invocation @@ -69,7 +69,7 @@ org_keys='{}' {% for org in github_runner_secrets_onepassword_orgs_to_read %} key_{{ loop.index }}=$(op read '{{ org.private_key_op_reference }}') - org_keys=$(jq -n --argjson prev "$org_keys" --arg name '{{ org.name }}' --arg key "$key_{{ loop.index }}" '$prev + {($name): $key}') + org_keys=$(jq -n --argjson prev "$org_keys" --arg name '{{ org.private_key_op_reference }}' --arg key "$key_{{ loop.index }}" '$prev + {($name): $key}') {% endfor %} jq -n --argjson secrets "$secrets" --argjson org_keys "$org_keys" '$secrets + {org_private_keys: $org_keys}' delegate_to: localhost diff --git a/tests/unit/test_onepassword.py b/tests/unit/test_onepassword.py index ea211da..2d5498a 100644 --- a/tests/unit/test_onepassword.py +++ b/tests/unit/test_onepassword.py @@ -16,21 +16,26 @@ class FillOrgKeysTest(unittest.TestCase): def test_a_reference_is_replaced_by_the_key_read_for_it(self) -> None: orgs = [{"name": "A", "app_id": 1, "private_key_op_reference": "op://v/a/key"}] - self.assertEqual(onepassword.onepassword_fill_org_keys(orgs, {"A": "pem-a"}), [{"name": "A", "app_id": 1, "private_key": "pem-a"}]) + self.assertEqual(onepassword.onepassword_fill_org_keys(orgs, {"op://v/a/key": "pem-a"}), [{"name": "A", "app_id": 1, "private_key": "pem-a"}]) def test_an_org_without_a_reference_is_left_alone(self) -> None: orgs = [{"name": "A", "private_key": "own"}, {"name": "B", "private_key_op_reference": "op://v/b/key"}] - self.assertEqual(onepassword.onepassword_fill_org_keys(orgs, {"A": "ignored", "B": "pem-b"}), [{"name": "A", "private_key": "own"}, {"name": "B", "private_key": "pem-b"}]) + self.assertEqual(onepassword.onepassword_fill_org_keys(orgs, {"op://v/a/key": "ignored", "op://v/b/key": "pem-b"}), [{"name": "A", "private_key": "own"}, {"name": "B", "private_key": "pem-b"}]) def test_the_input_is_not_changed(self) -> None: orgs = [{"name": "A", "private_key_op_reference": "op://v/a/key"}] - onepassword.onepassword_fill_org_keys(orgs, {"A": "pem-a"}) + onepassword.onepassword_fill_org_keys(orgs, {"op://v/a/key": "pem-a"}) self.assertEqual(orgs, [{"name": "A", "private_key_op_reference": "op://v/a/key"}]) def test_a_reference_with_no_key_read_is_an_error(self) -> None: - with self.assertRaisesRegex(onepassword.AnsibleFilterError, "for org A"): + with self.assertRaisesRegex(onepassword.AnsibleFilterError, "for A \\(op://v/a/key\\)"): onepassword.onepassword_fill_org_keys([{"name": "A", "private_key_op_reference": "op://v/a/key"}], {}) + def test_entries_of_one_login_get_the_key_their_own_reference_names(self) -> None: + orgs = [{"name": "example-org", "private_key_op_reference": "op://v/org/key"}, {"name": "example-org", "repository": "example-repo", "private_key_op_reference": "op://v/repo/key"}] + filled = onepassword.onepassword_fill_org_keys(orgs, {"op://v/org/key": "pem-org", "op://v/repo/key": "pem-repo"}) + self.assertEqual([entry["private_key"] for entry in filled], ["pem-org", "pem-repo"]) + def test_a_non_list_is_an_error(self) -> None: with self.assertRaises(onepassword.AnsibleFilterError): onepassword.onepassword_fill_org_keys("nope", {}) # type: ignore[arg-type] From f74c5a702ca01486e972a43771b028f7877c782a Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 25 Oct 2026 07:42:24 +0000 Subject: [PATCH 4/8] feat(arc): install a repository-scoped entry's scale sets A profile registers with its scope's githubConfigUrl, so a repository entry's scale sets register in that repository. Installation ids, pull tokens, an entry's profiles and an existing App Secret's app_id check are looked up by scope rather than by login, so an organisation entry and its repository entries never stand in for one another. A repository scope resolves its installation through GET /repos/{owner}/{repo}/installation, which answers only for an installation that can reach the repository. --- .../tasks/check_existing_secret_contents.yml | 6 +-- .../github_runner_arc/tasks/check_secrets.yml | 4 +- .../tasks/choose_platform_pull_secrets.yml | 2 +- roles/github_runner_arc/tasks/install_org.yml | 20 ++++---- .../tasks/install_scale_set_profile.yml | 4 +- roles/github_runner_arc/tasks/main.yml | 6 +-- .../tasks/mint_app_pull_token.yml | 38 +++++++------- .../tasks/resolve_installation_id.yml | 50 ++++++++++++++----- roles/github_runner_arc/vars/main.yml | 4 +- tests/unit/test_existing_secrets.py | 20 +++++++- tests/unit/test_pull_secret_source.py | 17 ++++--- 11 files changed, 108 insertions(+), 63 deletions(-) diff --git a/roles/github_runner_arc/tasks/check_existing_secret_contents.yml b/roles/github_runner_arc/tasks/check_existing_secret_contents.yml index 397db56..6c4cd36 100644 --- a/roles/github_runner_arc/tasks/check_existing_secret_contents.yml +++ b/roles/github_runner_arc/tasks/check_existing_secret_contents.yml @@ -29,16 +29,16 @@ - name: Check each existing App Secret holds what the controller reads ansible.builtin.fail: msg: >- - App Secret {{ item.0.namespace }}/{{ item.0.name }} of {{ item.1.org_name }} + App Secret {{ item.0.namespace }}/{{ item.0.name }} of {{ item.1.scope }} {{ ('has no ' ~ (github_runner_arc_missing_keys | join(', ')) ~ '; it needs github_app_id, github_app_installation_id and github_app_private_key.') if github_runner_arc_missing_keys | length > 0 else - ('holds github_app_id ' ~ item.0.app_id ~ ', but the org sets app_id ' ~ github_runner_arc_org_app_id ~ ". Correct or remove the org's app_id.") }} + ('holds github_app_id ' ~ item.0.app_id ~ ', but its github_runner_arc_orgs entry sets app_id ' ~ github_runner_arc_org_app_id ~ ". Correct or remove the entry's app_id.") }} loop: "{{ github_runner_arc_existing_secret_checks | from_json | zip(github_runner_arc_local.profiles) | list if not (github_runner_arc_manage_secrets | bool) else [] }}" loop_control: label: "{{ item.0.namespace }}/{{ item.0.name }}" vars: github_runner_arc_missing_keys: "{{ ['github_app_id', 'github_app_installation_id', 'github_app_private_key'] | reject('in', item.0.key_names) | list }}" - github_runner_arc_org_app_id: "{{ (github_runner_arc_orgs | selectattr('name', 'equalto', item.1.org_name) | first).app_id | default('', true) | string }}" + github_runner_arc_org_app_id: "{{ dict(github_runner_arc_orgs | map('exadev.github_runner.arc_scope') | zip(github_runner_arc_orgs | map(attribute='app_id', default=''))).get(item.1.scope) | default('', true) | string }}" when: - item.0.exists - github_runner_arc_missing_keys | length > 0 or (github_runner_arc_org_app_id | length > 0 and github_runner_arc_org_app_id != item.0.app_id) diff --git a/roles/github_runner_arc/tasks/check_secrets.yml b/roles/github_runner_arc/tasks/check_secrets.yml index 3dbc915..936630c 100644 --- a/roles/github_runner_arc/tasks/check_secrets.yml +++ b/roles/github_runner_arc/tasks/check_secrets.yml @@ -4,14 +4,14 @@ - name: Fail when an org's GitHub App private key is missing or is not a PEM key ansible.builtin.fail: msg: >- - No usable GitHub App private key for org {{ org.name }}: its private_key must hold the PEM key. + No usable GitHub App private key for {{ org | exadev.github_runner.arc_scope }}: its private_key must hold the PEM key. {{ 'Its private_key_op_reference is only read by the github_runner_secrets_onepassword adapter (github_runner_secrets_source: onepassword); use it, or replace the reference with private_key.' if org.private_key_op_reference is defined else 'Set it from any source Ansible reads, e.g. an ansible-vault string or a lookup.' }} loop: "{{ github_runner_arc_orgs }}" loop_control: loop_var: org - label: "{{ org.name }}" + label: "{{ org | exadev.github_runner.arc_scope }}" when: - github_runner_arc_manage_secrets | bool - org.private_key | default('') is not search('-----BEGIN [A-Z ]*PRIVATE KEY-----') diff --git a/roles/github_runner_arc/tasks/choose_platform_pull_secrets.yml b/roles/github_runner_arc/tasks/choose_platform_pull_secrets.yml index 11e2eb5..2d1a628 100644 --- a/roles/github_runner_arc/tasks/choose_platform_pull_secrets.yml +++ b/roles/github_runner_arc/tasks/choose_platform_pull_secrets.yml @@ -1,7 +1,7 @@ --- # Sets github_runner_arc_platform_image_pull_secrets, the pull Secrets the platform's own workloads use. Included by install_platform.yml and install_node_cache_pruner.yml, which run whether or not the other does. -# An App's installation token reads only its own organisation's packages, and the platform belongs to no one org, so with an App-sourced pull Secret the platform's Deployments pull without one, as they do with none. The role's default platform images are public. +# An App's installation token reads only the packages of the account it is installed on (an organisation, or a user for a user's repository scope), and the platform belongs to no one account, so with an App-sourced pull Secret the platform's Deployments pull without one, as they do with none. The role's default platform images are public. - name: "Platform: choose the pull Secret its Deployments use" ansible.builtin.set_fact: github_runner_arc_platform_image_pull_secrets: >- diff --git a/roles/github_runner_arc/tasks/install_org.yml b/roles/github_runner_arc/tasks/install_org.yml index db9c975..d3436e8 100644 --- a/roles/github_runner_arc/tasks/install_org.yml +++ b/roles/github_runner_arc/tasks/install_org.yml @@ -1,28 +1,28 @@ --- -# Included once per org from main.yml, with `org` set to its github_runner_arc_orgs entry. Resolves the installation id once per org, then installs each of the org's scale-set profiles through install_scale_set_profile.yml. +# Included once per github_runner_arc_orgs entry from main.yml, with `org` set to it. Resolves the installation id once per entry, keyed by its scope (an organisation, or one repository; see plugins/filter/arc.py's arc_scope), then installs each of the entry's scale-set profiles through install_scale_set_profile.yml. # The installation ID only goes into the App Secret, so it is not resolved when the role does not write that Secret. -- name: Resolve the installation ID of {{ org.name }} +- name: Resolve the installation ID of {{ org | exadev.github_runner.arc_scope }} ansible.builtin.include_tasks: resolve_installation_id.yml - when: github_runner_arc_manage_secrets | bool and (org.installation_id | default('') | string) | length == 0 and org.name not in (github_runner_arc_installation_ids | default({})) + when: github_runner_arc_manage_secrets | bool and (org.installation_id | default('') | string) | length == 0 and (org | exadev.github_runner.arc_scope) not in (github_runner_arc_installation_ids | default({})) -- name: Set the effective installation ID of {{ org.name }} - # The org's own installation_id when set, otherwise the one resolved for this org. The resolved ids are read directly by name: resolve_installation_id.yml sets them with a plain set_fact and no delegate_facts, so they attach to this host, not to the localhost it delegated to. +- name: Set the effective installation ID of {{ org | exadev.github_runner.arc_scope }} + # The entry's own installation_id when set, otherwise the one resolved for its scope. The resolved ids are read directly by scope: resolve_installation_id.yml sets them with a plain set_fact and no delegate_facts, so they attach to this host, not to the localhost it delegated to. ansible.builtin.set_fact: - github_runner_arc_installation_id: "{{ (org.installation_id | default('') | string) or github_runner_arc_installation_ids[org.name] }}" + github_runner_arc_installation_id: "{{ (org.installation_id | default('') | string) or github_runner_arc_installation_ids[org | exadev.github_runner.arc_scope] }}" when: github_runner_arc_manage_secrets | bool -- name: Install/upgrade each scale-set profile of {{ org.name }} +- name: Install/upgrade each scale-set profile of {{ org | exadev.github_runner.arc_scope }} ansible.builtin.include_tasks: install_scale_set_profile.yml - loop: "{{ github_runner_arc_local.profiles | selectattr('org_name', 'equalto', org.name) }}" + loop: "{{ github_runner_arc_local.profiles | selectattr('scope', 'equalto', org | exadev.github_runner.arc_scope) }}" loop_control: loop_var: profile label: "{{ profile.label }}" -- name: Trigger an immediate autoscaler reconcile after upgrading {{ org.name }} +- name: Trigger an immediate autoscaler reconcile after upgrading {{ org | exadev.github_runner.arc_scope }} # Every Helm upgrade above reverts maxRunners to its values file's floor; this shrinks that window to seconds instead of waiting for the autoscaler's next poll. Goes through the API server (kubectl exec) because the autoscaler is an unpinned in-cluster Deployment. Best-effort: the poll loop catches up regardless, including when the Deployment does not exist yet. ansible.builtin.command: kubectl exec deploy/autoscaler -n {{ github_runner_arc_platform_namespace }} -- /app/scripts/autoscaler.sh environment: "{{ {'KUBECONFIG': github_runner_arc_kubeconfig_path} if github_runner_arc_kubeconfig_path | length > 0 else {} }}" changed_when: false failed_when: false - when: github_runner_arc_fleet.autoscaled | selectattr('org_name', 'equalto', org.name) | list | length > 0 + when: github_runner_arc_fleet.autoscaled | selectattr('scope', 'equalto', org | exadev.github_runner.arc_scope) | list | length > 0 diff --git a/roles/github_runner_arc/tasks/install_scale_set_profile.yml b/roles/github_runner_arc/tasks/install_scale_set_profile.yml index 31ffa63..bc4250c 100644 --- a/roles/github_runner_arc/tasks/install_scale_set_profile.yml +++ b/roles/github_runner_arc/tasks/install_scale_set_profile.yml @@ -35,8 +35,8 @@ github_runner_arc_pull_secret_vars: >- {{ {'namespace': profile.namespace, 'username': github_runner_arc_app_pull_token_username, - 'password': github_runner_arc_app_pull_tokens[org.name].token, - 'annotations': {github_runner_arc_pull_token_expiry_annotation: github_runner_arc_app_pull_tokens[org.name].expires_at}} + 'password': github_runner_arc_app_pull_tokens[org | exadev.github_runner.arc_scope].token, + 'annotations': {github_runner_arc_pull_token_expiry_annotation: github_runner_arc_app_pull_tokens[org | exadev.github_runner.arc_scope].expires_at}} if github_runner_arc_image_pull_secret_source == 'app' else {'namespace': profile.namespace, 'username': github_runner_arc_ghcr_username, 'password': github_runner_arc_ghcr_token, 'annotations': {}} }} diff --git a/roles/github_runner_arc/tasks/main.yml b/roles/github_runner_arc/tasks/main.yml index 993349e..0cb6713 100644 --- a/roles/github_runner_arc/tasks/main.yml +++ b/roles/github_runner_arc/tasks/main.yml @@ -44,9 +44,9 @@ loop: "{{ github_runner_arc_orgs }}" loop_control: loop_var: org - label: "{{ org.name }}" + label: "{{ org | exadev.github_runner.arc_scope }}" vars: - github_runner_arc_org_first_profile: "{{ github_runner_arc_local.profiles | selectattr('org_name', 'equalto', org.name) | first }}" + github_runner_arc_org_first_profile: "{{ github_runner_arc_local.profiles | selectattr('scope', 'equalto', org | exadev.github_runner.arc_scope) | first }}" when: github_runner_arc_manage_image_pull_secret | bool and github_runner_arc_image_pull_secret_source == 'app' - name: Label the eligible nodes @@ -97,7 +97,7 @@ loop: "{{ github_runner_arc_orgs }}" loop_control: loop_var: org - label: "{{ org.name }}" + label: "{{ org | exadev.github_runner.arc_scope }}" # Last, so the stamp says a run applied everything above, and only when the platform namespace exists. Records what was applied, not what is running: a pod that failed to start still leaves the stamp. - name: Record the collection version this run applied diff --git a/roles/github_runner_arc/tasks/mint_app_pull_token.yml b/roles/github_runner_arc/tasks/mint_app_pull_token.yml index 1504fcc..ceccd9a 100644 --- a/roles/github_runner_arc/tasks/mint_app_pull_token.yml +++ b/roles/github_runner_arc/tasks/mint_app_pull_token.yml @@ -3,11 +3,11 @@ # # The App's credentials come from the org's own entry when the role writes the App Secret, or from that Secret in the org's first namespace when something else does. Signing and minting run on the control node, and like the installation id lookup they run under --check too: they change nothing in the cluster, and the check is only meaningful if the credential is real. -- name: Resolve the installation ID of {{ org.name }} +- name: Resolve the installation ID of {{ org | exadev.github_runner.arc_scope }} ansible.builtin.include_tasks: resolve_installation_id.yml - when: github_runner_arc_manage_secrets | bool and (org.installation_id | default('') | string) | length == 0 and org.name not in (github_runner_arc_installation_ids | default({})) + when: github_runner_arc_manage_secrets | bool and (org.installation_id | default('') | string) | length == 0 and (org | exadev.github_runner.arc_scope) not in (github_runner_arc_installation_ids | default({})) -- name: Read the App Secret of {{ org.name }} +- name: Read the App Secret of {{ org | exadev.github_runner.arc_scope }} kubernetes.core.k8s_info: kind: Secret namespace: "{{ github_runner_arc_org_first_profile.namespace }}" @@ -16,12 +16,12 @@ no_log: true when: not (github_runner_arc_manage_secrets | bool) -- name: Take the App's credentials for {{ org.name }} +- name: Take the App's credentials for {{ org | exadev.github_runner.arc_scope }} ansible.builtin.set_fact: github_runner_arc_mint_app: >- {{ {'id': org.app_id | string, - 'installation_id': (org.installation_id | default('') | string) or github_runner_arc_installation_ids[org.name], + 'installation_id': (org.installation_id | default('') | string) or github_runner_arc_installation_ids[org | exadev.github_runner.arc_scope], 'private_key': org.private_key} if github_runner_arc_manage_secrets | bool else {'id': github_runner_arc_mint_app_secret.resources[0].data.github_app_id | b64decode, @@ -30,24 +30,24 @@ }} no_log: true -- name: Mint a packages read token from the App of {{ org.name }} +- name: Mint a packages read token from the App of {{ org | exadev.github_runner.arc_scope }} delegate_to: localhost check_mode: false block: - - name: Create a temporary file for the private key of {{ org.name }} + - name: Create a temporary file for the private key of {{ org | exadev.github_runner.arc_scope }} ansible.builtin.tempfile: state: file suffix: ".pem" register: github_runner_arc_mint_private_key_tempfile - - name: Write the private key to the temporary file for {{ org.name }} + - name: Write the private key to the temporary file for {{ org | exadev.github_runner.arc_scope }} ansible.builtin.copy: content: "{{ github_runner_arc_mint_app.private_key }}" dest: "{{ github_runner_arc_mint_private_key_tempfile.path }}" mode: "0600" no_log: true - - name: Sign an App JWT for {{ org.name }} + - name: Sign an App JWT for {{ org | exadev.github_runner.arc_scope }} ansible.builtin.script: cmd: "generate_app_jwt.py {{ github_runner_arc_mint_app.id | quote }} {{ github_runner_arc_mint_private_key_tempfile.path | quote }}" executable: python3 @@ -56,7 +56,7 @@ register: github_runner_arc_mint_jwt # Restricted to packages: read, whatever else the App may do. GitHub answers 422 when the App or this installation lacks the permission, which the next task reports. - - name: Mint the installation token for {{ org.name }} + - name: Mint the installation token for {{ org | exadev.github_runner.arc_scope }} ansible.builtin.uri: url: "https://api.github.com/app/installations/{{ github_runner_arc_mint_app.installation_id }}/access_tokens" method: POST @@ -72,26 +72,26 @@ no_log: true register: github_runner_arc_mint_response always: - - name: Remove the private key temporary file for {{ org.name }} + - name: Remove the private key temporary file for {{ org | exadev.github_runner.arc_scope }} ansible.builtin.file: path: "{{ github_runner_arc_mint_private_key_tempfile.path }}" state: absent when: github_runner_arc_mint_private_key_tempfile.path is defined -- name: Fail when GitHub refused the token for {{ org.name }} +- name: Fail when GitHub refused the token for {{ org | exadev.github_runner.arc_scope }} ansible.builtin.fail: msg: >- - GitHub refused to mint a packages: read installation token from the App of {{ org.name }} + GitHub refused to mint a packages: read installation token from the App of {{ org | exadev.github_runner.arc_scope }} (HTTP {{ github_runner_arc_mint_response.status }}: {{ github_runner_arc_mint_response.json.message | default('no message') }}). - The App needs the packages: read permission, and the organisation must approve it on the App's installation. + The App needs the packages: read permission, and the account it is installed on (the organisation, or the user for a user's repository) must approve it on the App's installation. when: github_runner_arc_mint_response.status != 201 -- name: Record the pull token of {{ org.name }} +- name: Record the pull token of {{ org | exadev.github_runner.arc_scope }} ansible.builtin.set_fact: - github_runner_arc_app_pull_tokens: "{{ github_runner_arc_app_pull_tokens | default({}) | combine({org.name: {'token': github_runner_arc_mint_response.json.token, 'expires_at': github_runner_arc_mint_response.json.expires_at}}) }}" + github_runner_arc_app_pull_tokens: "{{ github_runner_arc_app_pull_tokens | default({}) | combine({(org | exadev.github_runner.arc_scope): {'token': github_runner_arc_mint_response.json.token, 'expires_at': github_runner_arc_mint_response.json.expires_at}}) }}" no_log: true -- name: Prove the token can read the image of {{ org.name }} +- name: Prove the token can read the image of {{ org | exadev.github_runner.arc_scope }} ansible.builtin.include_tasks: verify_image_pull_one.yml when: github_runner_arc_verify_image_pull | bool loop: "{{ [org.image] | map('exadev.github_runner.arc_image_ref') | selectattr('registry', 'equalto', github_runner_arc_image_pull_registry) | list }}" @@ -100,6 +100,6 @@ label: "{{ image.registry }}/{{ image.repository }}:{{ image.reference }}" vars: github_runner_arc_pull_username: "{{ github_runner_arc_app_pull_token_username }}" - github_runner_arc_pull_password: "{{ github_runner_arc_app_pull_tokens[org.name].token }}" - github_runner_arc_pull_credential: "the packages: read token from the App of {{ org.name }}" + github_runner_arc_pull_password: "{{ github_runner_arc_app_pull_tokens[org | exadev.github_runner.arc_scope].token }}" + github_runner_arc_pull_credential: "the packages: read token from the App of {{ org | exadev.github_runner.arc_scope }}" github_runner_arc_pull_credential_hint: "Check the App has the packages: read permission, approved on its installation, and that the package is visible to it." diff --git a/roles/github_runner_arc/tasks/resolve_installation_id.yml b/roles/github_runner_arc/tasks/resolve_installation_id.yml index a717fd5..da070df 100644 --- a/roles/github_runner_arc/tasks/resolve_installation_id.yml +++ b/roles/github_runner_arc/tasks/resolve_installation_id.yml @@ -1,28 +1,37 @@ --- -# Included once per org whose installation_id is not set, and records it in github_runner_arc_installation_ids under the org's name (see install_org.yml). Signs a short-lived App JWT with files/generate_app_jwt.py and finds this org's installation in GET /app/installations, using the org's own private_key. +# Included once per github_runner_arc_orgs entry whose installation_id is not set, with `org` set to the entry, and records it in github_runner_arc_installation_ids under the entry's scope (plugins/filter/arc.py's arc_scope; see install_org.yml). Signs a short-lived App JWT with files/generate_app_jwt.py, using the entry's own private_key. An organisation scope finds the installation whose account.login is the organisation in GET /app/installations (account is a user or an organisation there, so a login matches either). A repository scope asks GET /repos/{owner}/{repo}/installation instead, which answers only when the App is installed where it can reach that repository, so an installation on the account that leaves the repository out fails here rather than as a listener that cannot register. # # Runs entirely on the control node: nothing here needs the target host, and delegating keeps the temporary key file off it. # # No delegate_facts anywhere, deliberately: with it unset, a delegated task's facts attach to inventory_hostname, which is what install_org.yml reads. delegate_facts: true would send them to localhost instead, which once left the deployed App Secret's installation_id empty. - name: Resolve the installation ID via a signed App JWT delegate_to: localhost + vars: + github_runner_arc_resolve_scope: "{{ org | exadev.github_runner.arc_scope }}" + github_runner_arc_resolved_installation_id: >- + {{ + (github_runner_arc_resolve_repository_installation.json.id | string + if github_runner_arc_resolve_repository_installation.status == 200 else '') + if org.repository | default('', true) | length > 0 else + (github_runner_arc_resolve_installations.json | selectattr('account.login', 'equalto', org.name) | map(attribute='id') | map('string') | first | default('')) + }} # Read-only apart from a temp file it removes again, and the org's secrets need the result, so it runs under --check too. check_mode: false block: - - name: Create a temporary file for the private key of {{ org.name }} + - name: Create a temporary file for the private key of {{ org | exadev.github_runner.arc_scope }} ansible.builtin.tempfile: state: file suffix: ".pem" register: github_runner_arc_resolve_private_key_tempfile - - name: Write the private key to the temporary file for {{ org.name }} + - name: Write the private key to the temporary file for {{ org | exadev.github_runner.arc_scope }} ansible.builtin.copy: content: "{{ org.private_key }}" dest: "{{ github_runner_arc_resolve_private_key_tempfile.path }}" mode: "0600" no_log: true - - name: Sign an App JWT for {{ org.name }} + - name: Sign an App JWT for {{ org | exadev.github_runner.arc_scope }} ansible.builtin.script: cmd: "generate_app_jwt.py {{ org.app_id | quote }} {{ github_runner_arc_resolve_private_key_tempfile.path | quote }}" executable: python3 @@ -30,7 +39,7 @@ no_log: true register: github_runner_arc_resolve_jwt - - name: List the App's installations for {{ org.name }} + - name: List the App's installations for {{ org | exadev.github_runner.arc_scope }} ansible.builtin.uri: url: https://api.github.com/app/installations?per_page=100 headers: @@ -39,20 +48,35 @@ X-GitHub-Api-Version: "2022-11-28" no_log: true register: github_runner_arc_resolve_installations + when: org.repository | default('', true) | length == 0 - - name: Fail if the App has no installation on {{ org.name }} + - name: Find the App's installation that reaches {{ github_runner_arc_resolve_scope }} + ansible.builtin.uri: + url: "https://api.github.com/repos/{{ org.name | urlencode }}/{{ org.repository | urlencode }}/installation" + headers: + Authorization: "Bearer {{ github_runner_arc_resolve_jwt.stdout | trim }}" + Accept: application/vnd.github+json + X-GitHub-Api-Version: "2022-11-28" + status_code: [200, 404] + no_log: true + register: github_runner_arc_resolve_repository_installation + when: org.repository | default('', true) | length > 0 + + - name: Fail if the App has no installation for {{ github_runner_arc_resolve_scope }} ansible.builtin.fail: - msg: "Could not resolve an installation id for {{ org.name }}. Is the GitHub App installed on that org?" + msg: >- + {{ ('Could not resolve an installation id for ' ~ github_runner_arc_resolve_scope ~ '. Is the GitHub App installed on ' + ~ org.name ~ ', with ' ~ org.repository ~ ' among its repositories?') + if org.repository | default('', true) | length > 0 else + ('Could not resolve an installation id for ' ~ org.name ~ '. Is the GitHub App installed on that org?') }} when: github_runner_arc_resolved_installation_id | length == 0 - vars: - github_runner_arc_resolved_installation_id: "{{ github_runner_arc_resolve_installations.json | selectattr('account.login', 'equalto', org.name) | map(attribute='id') | map('string') | first | default('') }}" - # Keyed by org, so that one org's resolved id can never stand in for another org's. - - name: Record the installation ID of {{ org.name }} + # Keyed by scope, so that one entry's resolved id can never stand in for another's, even of the same login. + - name: Record the installation ID of {{ github_runner_arc_resolve_scope }} ansible.builtin.set_fact: - github_runner_arc_installation_ids: "{{ github_runner_arc_installation_ids | default({}) | combine({org.name: github_runner_arc_resolve_installations.json | selectattr('account.login', 'equalto', org.name) | map(attribute='id') | map('string') | first}) }}" + github_runner_arc_installation_ids: "{{ github_runner_arc_installation_ids | default({}) | combine({github_runner_arc_resolve_scope: github_runner_arc_resolved_installation_id}) }}" always: - - name: Remove the private key temporary file for {{ org.name }} + - name: Remove the private key temporary file for {{ org | exadev.github_runner.arc_scope }} ansible.builtin.file: path: "{{ github_runner_arc_resolve_private_key_tempfile.path }}" state: absent diff --git a/roles/github_runner_arc/vars/main.yml b/roles/github_runner_arc/vars/main.yml index 9306933..dec41ba 100644 --- a/roles/github_runner_arc/vars/main.yml +++ b/roles/github_runner_arc/vars/main.yml @@ -16,10 +16,10 @@ github_runner_arc_node_recovery_annotation: github-runner.exadev/out-of-service- # Whether the runner pods and the platform's Deployments reference a pull Secret at all. With github_runner_arc_image_pull_secret_source none every image they run is public, so the role writes, checks and references none, and removes one an earlier run wrote. github_runner_arc_pull_secret_in_use: "{{ github_runner_arc_image_pull_secret_source != 'none' }}" -# The values install_scale_set_profile.yml passes to Helm with --set for a profile, with `org` and `profile` set: the org's URL and App Secret, the scale set's name (the release name, whatever a values file says, since the name is the scale set's registration identity on GitHub and arc_cluster_profiles compares releases to refuse one registered from two clusters), the pull Secret (only while one is in use), the runner image, and the profile's scaleSetLabels one index at a time. --set rather than values for containers[] and the label list, since Helm replaces lists wholesale, so a values mapping naming containers would drop the values file's own command and resources for that container. +# The values install_scale_set_profile.yml passes to Helm with --set for a profile, with `org` and `profile` set: the URL of the scope the scale set registers in (an organisation, or one repository; see plugins/filter/arc.py's arc_scope) and the App Secret, the scale set's name (the release name, whatever a values file says, since the name is the scale set's registration identity on GitHub and arc_cluster_profiles compares releases to refuse one registered from two clusters), the pull Secret (only while one is in use), the runner image, and the profile's scaleSetLabels one index at a time. --set rather than values for containers[] and the label list, since Helm replaces lists wholesale, so a values mapping naming containers would drop the values file's own command and resources for that container. github_runner_arc_scale_set_set_values: >- {{ - [{'value': 'githubConfigUrl=https://github.com/' ~ org.name}, {'value': 'githubConfigSecret=' ~ profile.app_secret}, {'value': 'runnerScaleSetName=' ~ profile.release}] + [{'value': 'githubConfigUrl=' ~ profile.config_url}, {'value': 'githubConfigSecret=' ~ profile.app_secret}, {'value': 'runnerScaleSetName=' ~ profile.release}] + ([{'value': 'template.spec.imagePullSecrets[0].name=' ~ github_runner_arc_image_pull_secret_name}] if github_runner_arc_pull_secret_in_use | bool else []) + [{'value': 'template.spec.containers[0].image=' ~ org.image}] + range(profile.scale_set_labels | length) | zip(profile.scale_set_labels) | map('join', ']=') | map('regex_replace', '^', 'scaleSetLabels[') diff --git a/tests/unit/test_existing_secrets.py b/tests/unit/test_existing_secrets.py index 0393ec9..b491a39 100644 --- a/tests/unit/test_existing_secrets.py +++ b/tests/unit/test_existing_secrets.py @@ -54,7 +54,12 @@ def run(result: dict[str, Any], app_id: Any = None, manage_secrets: bool = False org: dict[str, Any] = {"name": "example-org", "image": "ghcr.io/example/runner:1", "app_secret_name": "example-github-app", "scale_set_profiles": [{"namespace": "example-runners", "max_runners": 1}]} if app_id is not None: org["app_id"] = app_id - play_vars = {"github_runner_arc_orgs": [org], "github_runner_arc_manage_secrets": manage_secrets, "github_runner_arc_existing_secrets": {"results": [result]}} + return run_entries([org], [result], manage_secrets) + + +def run_entries(orgs: list[dict[str, Any]], results: list[dict[str, Any]], manage_secrets: bool = False) -> subprocess.CompletedProcess[str]: + """Run the checks against the App Secret lookup results of orgs' profiles, in profile order, at -vvv.""" + play_vars = {"github_runner_arc_orgs": orgs, "github_runner_arc_manage_secrets": manage_secrets, "github_runner_arc_existing_secrets": {"results": results}} with tempfile.TemporaryDirectory() as directory: play = Path(directory) / "play.yml" play.write_text(PLAYBOOK.format(play_vars=json.dumps(play_vars))) @@ -81,7 +86,7 @@ def test_an_app_id_matching_the_secret_passes(self) -> None: def test_an_app_id_disagreeing_with_the_secret_fails(self) -> None: result = run(app_secret(FULL), app_id=999) self.assertNotEqual(result.returncode, 0) - self.assertIn("App Secret example-runners/example-github-app of example-org holds github_app_id 123, but the org sets app_id 999. Correct or remove the org's app_id.", result.stdout) + self.assertIn("App Secret example-runners/example-github-app of example-org holds github_app_id 123, but its github_runner_arc_orgs entry sets app_id 999. Correct or remove the entry's app_id.", result.stdout) self.assert_nothing_leaked(result) def test_a_secret_missing_keys_fails_naming_them(self) -> None: @@ -95,6 +100,17 @@ def test_a_missing_secret_fails(self) -> None: self.assertNotEqual(result.returncode, 0) self.assertIn("Secret example-github-app does not exist in namespace example-runners.", result.stdout) + def test_each_entry_of_one_login_is_checked_against_its_own_app_id(self) -> None: + # The organisation's entry comes first and agrees with its Secret, so only a lookup by scope finds the repository entry's own app_id. + organisation = {"name": "example-org", "app_id": 123, "image": "ghcr.io/example/runner:1", "scale_set_profiles": [{"namespace": "example-runners", "max_runners": 1}]} + repository = {"name": "example-org", "repository": "example-repo", "app_id": 999, "image": "ghcr.io/example/runner:1", "scale_set_profiles": [{"namespace": "example-repo-runners", "max_runners": 1}]} + secrets = [app_secret(FULL) | {"item": ["example-runners", "example-org-github-app"]}, app_secret(FULL) | {"item": ["example-repo-runners", "example-org--example-repo-github-app"]}] + result = run_entries([organisation, repository], secrets) + self.assertNotEqual(result.returncode, 0) + self.assertIn("App Secret example-repo-runners/example-org--example-repo-github-app of example-org/example-repo holds github_app_id 123, but its github_runner_arc_orgs entry sets app_id 999.", result.stdout) + self.assertNotIn("App Secret example-runners/example-org-github-app of", result.stdout) + self.assert_nothing_leaked(result) + def test_a_host_writing_its_app_secrets_does_not_check_their_contents(self) -> None: result = run(app_secret({"github_app_id": "123"}), app_id=999, manage_secrets=True) self.assertEqual(result.returncode, 0, result.stdout[-3000:]) diff --git a/tests/unit/test_pull_secret_source.py b/tests/unit/test_pull_secret_source.py index 328373d..fea8693 100644 --- a/tests/unit/test_pull_secret_source.py +++ b/tests/unit/test_pull_secret_source.py @@ -1,4 +1,4 @@ -"""Tests for what github_runner_arc_image_pull_secret_source decides about the pull Secret, rendered by ansible-playbook from the role's own defaults and vars: whether one is in use, and whether the Helm values a profile is installed with reference it.""" +"""Tests for the Helm values a profile is installed with, rendered by ansible-playbook from the role's own defaults and vars for a profile arc_profiles expands: what github_runner_arc_image_pull_secret_source decides about the pull Secret (whether one is in use, and whether the values reference it), and the githubConfigUrl of an organisation or repository scope.""" from __future__ import annotations @@ -30,22 +30,23 @@ content: "{{{{ {{'in_use': github_runner_arc_pull_secret_in_use | bool, 'set_values': github_runner_arc_scale_set_set_values}} | to_json }}}}" dest: "{{{{ output }}}}" mode: "0644" + vars: + profile: "{{{{ ([org] | exadev.github_runner.arc_profiles).profiles[0] }}}}" """ ) -ORG = {"name": "ExampleOrg", "image": "ghcr.io/actions/actions-runner:2.338.0"} -PROFILE = {"app_secret": "example-github-app", "release": "example-release", "scale_set_labels": ["example-pool"]} +ORG = {"name": "ExampleOrg", "app_id": 1, "image": "ghcr.io/actions/actions-runner:2.338.0", "app_secret_name": "example-github-app", "scale_set_profiles": [{"release_name": "example-release", "scale_set_labels": ["example-pool"], "max_runners": 1}]} -def render(source: str) -> dict[str, Any]: - """Return the role's pull Secret decisions for one source, with the example org and profile.""" +def render(source: str, org: dict[str, Any] = ORG) -> dict[str, Any]: + """Return the role's pull Secret decisions and Helm values for one source, with org's first profile.""" with tempfile.TemporaryDirectory() as work: play = Path(work) / "play.yml" play.write_text(PLAYBOOK) output = Path(work) / "out.json" extra = Path(work) / "extra.json" # Extra variables, since the defaults file loaded by the play outranks play variables. - extra.write_text(json.dumps({"output": str(output), "org": ORG, "profile": PROFILE, "github_runner_arc_image_pull_secret_source": source})) + extra.write_text(json.dumps({"output": str(output), "org": org, "github_runner_arc_image_pull_secret_source": source})) env = {**os.environ, "ANSIBLE_COLLECTIONS_PATH": str(ROOT / "playbooks" / "collections"), "ANSIBLE_LOCALHOST_WARNING": "false", "ANSIBLE_INVENTORY_UNPARSED_WARNING": "false", "ANSIBLE_HOME": work, "ANSIBLE_LOCAL_TEMP": work} subprocess.run([os.environ.get("ANSIBLE_PLAYBOOK", "ansible-playbook"), str(play), "-e", f"@{extra}"], check=True, env=env, cwd=work, capture_output=True, text=True, stdin=subprocess.DEVNULL) rendered: dict[str, Any] = json.loads(output.read_text()) @@ -79,6 +80,10 @@ def test_none_leaves_the_pull_secret_out_and_keeps_the_rest(self) -> None: ], ) + def test_a_repository_entry_registers_with_the_repositorys_url(self) -> None: + rendered = render("none", {**ORG, "name": "example-user", "repository": "example-repo"}) + self.assertEqual(set_values(rendered)[0], "githubConfigUrl=https://github.com/example-user/example-repo") + if __name__ == "__main__": unittest.main() From c78be49b5670b21138e52743ffa59219ad9ad3be Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 25 Oct 2026 07:48:21 +0000 Subject: [PATCH 5/8] feat(arc): create a GitHub App for one repository's runners github_runner_arc_app_setup_repository makes the manifest flow create an App for a repository entry: it asks for the repository administration write and metadata read permissions ARC documents for that scope, waits for an installation that reaches the repository, and writes the App Secret into that entry's namespaces under its derived name. github_runner_arc_app_setup_owner_type user posts the manifest to a personal account's App settings, and fails without a repository, since a personal account has no organisation runners. An empty github_runner_arc_app_setup_permissions now means the scope's documented permissions, and arc_default_app_secret gives the setup and the profile expansion one derivation of the App Secret's name. --- plugins/filter/arc.py | 8 ++- roles/github_runner_arc/defaults/main.yml | 20 ++++-- .../github_runner_arc/meta/argument_specs.yml | 25 +++++-- roles/github_runner_arc/tasks/app_setup.yml | 64 ++++++++++++++---- .../github-app-manifest-form.html.j2 | 9 +-- roles/github_runner_arc/vars/main.yml | 10 +++ tests/unit/test_app_setup.py | 67 +++++++++++++++++++ tests/unit/test_arc_filters.py | 4 ++ 8 files changed, 174 insertions(+), 33 deletions(-) diff --git a/plugins/filter/arc.py b/plugins/filter/arc.py index 9467f81..5d02685 100644 --- a/plugins/filter/arc.py +++ b/plugins/filter/arc.py @@ -564,6 +564,11 @@ def _scope_slug(org_name: str, repository: str) -> str: return f"{org_name.lower()}{_REPOSITORY_NAME_SEPARATOR}{_NOT_IN_DNS_LABEL.sub('-', repository.lower())}" +def arc_default_app_secret(org: Mapping[str, Any]) -> str: + """Return the App Secret name an entry's profiles use when it sets no app_secret_name: -github-app for an organisation scope, ---github-app for a repository scope (see _scope_slug).""" + return f"{_scope_slug(_text(org, 'name'), _text(org, 'repository'))}-github-app" + + def _expand_profile(org_name: str, repository: str, runner_image: str, app_secret: str, index: int, profile: Any, errors: list[str]) -> dict[str, Any] | None: """Describe one scale-set profile of an entry, or record why it cannot be described.""" scope = f"{org_name}/{repository}" if repository else org_name @@ -696,7 +701,7 @@ def arc_profiles(orgs: Sequence[Any], require_app_id: bool = True) -> dict[str, if not isinstance(org_profiles, Sequence) or isinstance(org_profiles, (str, bytes)) or not org_profiles: errors.append(f"{scope}: scale_set_profiles must be a non-empty list") continue - app_secret = _optional_name(org, "app_secret_name", scope, errors) or f"{_scope_slug(org_name, repository)}-github-app" + app_secret = _optional_name(org, "app_secret_name", scope, errors) or arc_default_app_secret(org) for profile_index, profile in enumerate(org_profiles): expanded = _expand_profile(org_name, repository, _text(org, "image"), app_secret, profile_index, profile, errors) if expanded is not None: @@ -1962,6 +1967,7 @@ def filters(self) -> dict[str, Any]: return { "arc_profiles": arc_profiles, "arc_scope": arc_scope, + "arc_default_app_secret": arc_default_app_secret, "arc_cluster_profiles": arc_cluster_profiles, "arc_max_runners": arc_max_runners, "arc_measured_max_runners": arc_measured_max_runners, diff --git a/roles/github_runner_arc/defaults/main.yml b/roles/github_runner_arc/defaults/main.yml index 8b87108..9728ef5 100644 --- a/roles/github_runner_arc/defaults/main.yml +++ b/roles/github_runner_arc/defaults/main.yml @@ -204,14 +204,18 @@ github_runner_arc_runner_reaper_image: "{{ github_runner_arc_image_pull_secret_r # A backstop that needs no GitHub call: activeDeadlineSeconds on every runner pod, after which the kubelet stops the pod whatever it is doing and ARC treats it as a failed runner. It counts from when the pod starts, including any time the runner waits idle for a job, so set it above the longest a runner may wait plus the longest job a fleet runs. Unset leaves the runner pods without one. github_runner_arc_runner_active_deadline_seconds: -# GitHub App creation through the manifest flow (tasks/app_setup.yml, run by playbooks/github_app_setup.yml). The App needs only organisation self-hosted runner write access; add packages: read to the permissions if its installation token should also pull private packages, and actions: read if the runner reaper is on. +# GitHub App creation through the manifest flow (tasks/app_setup.yml, run by playbooks/github_app_setup.yml). github_runner_arc_app_setup_org is the login of the account the App is registered and installed on: an organisation, or with github_runner_arc_app_setup_owner_type user a personal account. github_runner_arc_app_setup_org: "" +# organization registers the App on the organisation (GitHub's organisation App settings), user on a personal account (the account's own App settings). A personal account has no organisation runners, so user needs github_runner_arc_app_setup_repository. +github_runner_arc_app_setup_owner_type: organization +# A repository of that account, for an App whose scale sets register in that repository alone (a github_runner_arc_orgs entry with repository). Empty means an App for the organisation's own scale sets. +github_runner_arc_app_setup_repository: "" github_runner_arc_app_setup_name: "" github_runner_arc_app_setup_description: "Registers self-hosted GitHub Actions runners through Actions Runner Controller." -# The App's homepage and the page GitHub redirects to with the one-time code. Empty means the organisation's GitHub page. +# The App's homepage and the page GitHub redirects to with the one-time code. Empty means the account's GitHub page. github_runner_arc_app_setup_homepage_url: "" -github_runner_arc_app_setup_permissions: - organization_self_hosted_runners: write +# The App's permissions. Empty means what ARC documents for the scope (github_runner_arc_app_setup_scope_permissions in vars/main.yml): organisation self-hosted runners write for an organisation, repository administration write and metadata read for a repository. A value replaces them whole, so to add packages: read (the installation token also pulls private packages) or actions: read (the runner reaper is on) name the scope's own permissions beside it. +github_runner_arc_app_setup_permissions: {} github_runner_arc_app_manifest_code: "" github_runner_arc_app_private_key_path: "" # How long phase 2 waits for the App to be installed: retries times delay seconds. @@ -219,13 +223,15 @@ github_runner_arc_app_setup_install_retries: 40 github_runner_arc_app_setup_install_delay: 15 # The phase 2 command the manifest form and phase 1's instructions tell the person to run, without the code and key path, which the role appends. Its default runs this collection's playbook from a checkout; a playbook that wraps the flow names itself here, with whatever arguments it needs. github_runner_arc_app_setup_command: >- - ansible-playbook playbooks/github_app_setup.yml -e github_runner_arc_app_setup_org={{ github_runner_arc_app_setup_org }} + ansible-playbook playbooks/github_app_setup.yml -e github_runner_arc_app_setup_org={{ github_runner_arc_app_setup_org }}{{ + ' -e github_runner_arc_app_setup_owner_type=' ~ github_runner_arc_app_setup_owner_type if github_runner_arc_app_setup_owner_type != 'organization' else '' }}{{ + ' -e github_runner_arc_app_setup_repository=' ~ github_runner_arc_app_setup_repository if github_runner_arc_app_setup_repository | length > 0 else '' }} -e '{"github_runner_arc_app_setup_name": "{{ github_runner_arc_app_setup_name }}"}' # Whether phase 2 writes the App Secret (github_app_id, github_app_installation_id and github_app_private_key) into the cluster once the App is installed, through github_runner_arc_kubeconfig_path, creating the namespaces it needs. The cluster is reached, and the namespaces created, before the one-time code is used, so a broken cluster path costs only a rerun. false leaves the key in its file for the operator to store. github_runner_arc_app_setup_write_secret: false -# The namespaces the App Secret is written into. Empty means those of the organisation's scale-set profiles in github_runner_arc_orgs. +# The namespaces the App Secret is written into. Empty means those of the scale-set profiles of the github_runner_arc_orgs entry for the App's scope (the organisation, or with github_runner_arc_app_setup_repository that repository). github_runner_arc_app_setup_secret_namespaces: [] -# The App Secret's name. Empty means the organisation's app_secret_name in github_runner_arc_orgs, or -github-app, as the role's install uses. +# The App Secret's name. Empty means the app_secret_name of the github_runner_arc_orgs entry for the App's scope, or the name the role's install derives for that scope (-github-app, or ---github-app). github_runner_arc_app_setup_secret_name: "" # Whether to keep the private key file once the App Secret holds the key. false removes it, so the key is on disk only until the Secret is written; a run that stops earlier, for example because nobody installed the App within the wait, leaves the file so the App can still be finished without a new code. github_runner_arc_app_setup_keep_private_key: false diff --git a/roles/github_runner_arc/meta/argument_specs.yml b/roles/github_runner_arc/meta/argument_specs.yml index 875dc97..e11ea35 100644 --- a/roles/github_runner_arc/meta/argument_specs.yml +++ b/roles/github_runner_arc/meta/argument_specs.yml @@ -11,8 +11,9 @@ argument_specs: elements: dict default: [] description: - - 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 -github-app). - - Each entry's scale_set_profiles is a non-empty list of profiles, each with suffix and optionally namespace (default arc-runners-), release_name (default -runners), values_file, max_runners, min_runners, container_mode, resources, node_selector, runs_on_label, scale_set_labels, capabilities, capability_store, tolerations, autoscale, sizing and burst. + - Scopes 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 repository, installation_id and app_secret_name (the App Secret's name, default -github-app). + - Without repository an entry registers organisation runners in the organisation name names (githubConfigUrl https://github.com/). With repository it registers repository runners in that one repository (https://github.com//), where name may be a user or an organisation; its derived names use -- (lower-cased, with any character a DNS label does not allow as '-') in place of , and its App needs the repository-scope permissions. The scope (name, or name/repository) identifies an entry, so one login may have an organisation entry and any number of repository entries. + - Each entry's scale_set_profiles is a non-empty list of profiles, each with suffix and optionally namespace (default arc-runners-), release_name (default -runners, at most 53 characters, Helm's limit), values_file, max_runners, min_runners, container_mode, resources, node_selector, runs_on_label, scale_set_labels, capabilities, capability_store, tolerations, autoscale, sizing and burst. - capability_store, a boolean (default false), makes the profile's runner pods copy each tool-cache and system-library capability's payload, and the runner's externals for a Docker-in-Docker sidecar the values write out, into the capability store (github_runner_arc_capability_store_host_path), which the pods of a node share, instead of into every pod's own volumes, and mount the result read-only. Every image those copies come from must be pinned by digest, and the store needs the node cache pruner, so a profile that sets it is refused while github_runner_arc_node_cache_size_limit_gib is 0 or github_runner_arc_node_label_key is empty, since the pruner runs only on the nodes carrying that label. A job that can reach a privileged Docker daemon can write to the store, so give it only to a profile whose workflows are all of one trust level. - max_runners, when set, is the release's maxRunners and takes precedence over its values file and its sizing. - scale_set_labels replaces runs_on_label with the whole list of scaleSetLabels; an empty list sets none, so jobs target the release name. @@ -412,7 +413,16 @@ argument_specs: github_runner_arc_app_setup_org: type: str default: "" - description: Organisation the App setup creates the App on. + description: Login of the account the App setup creates the App on and waits for it to be installed on, an organisation or with github_runner_arc_app_setup_owner_type user a personal account. + github_runner_arc_app_setup_owner_type: + type: str + choices: [organization, user] + default: organization + description: Whether github_runner_arc_app_setup_org is an organisation or a personal account, which decides where GitHub's manifest flow registers the App. user needs github_runner_arc_app_setup_repository. + github_runner_arc_app_setup_repository: + type: str + default: "" + description: A repository of that account whose runners the App is for, matching a github_runner_arc_orgs entry with repository. Empty means the organisation's own runners. github_runner_arc_app_setup_name: type: str default: "" @@ -423,10 +433,11 @@ argument_specs: github_runner_arc_app_setup_homepage_url: type: str default: "" - description: The App's homepage and redirect page. Empty means the organisation's GitHub page. + description: The App's homepage and redirect page. Empty means the account's GitHub page. github_runner_arc_app_setup_permissions: type: dict - description: The App's permissions. + default: {} + description: The App's permissions. Empty means those ARC documents for the scope (organisation self-hosted runners write for an organisation; repository administration write and metadata read for a repository); a value replaces them whole. github_runner_arc_app_manifest_code: type: str default: "" @@ -448,11 +459,11 @@ argument_specs: type: list elements: str default: [] - description: Namespaces the App Secret is written into. Empty means those of the organisation's scale-set profiles in github_runner_arc_orgs. + description: Namespaces the App Secret is written into. Empty means those of the scale-set profiles of the github_runner_arc_orgs entry for the App's scope. github_runner_arc_app_setup_secret_name: type: str default: "" - description: The App Secret's name. Empty means the organisation's app_secret_name in github_runner_arc_orgs, or -github-app. + description: The App Secret's name. Empty means the app_secret_name of the github_runner_arc_orgs entry for the App's scope, or the name the role derives for that scope. github_runner_arc_app_setup_keep_private_key: type: bool default: false diff --git a/roles/github_runner_arc/tasks/app_setup.yml b/roles/github_runner_arc/tasks/app_setup.yml index 199d0a9..b73435a 100644 --- a/roles/github_runner_arc/tasks/app_setup.yml +++ b/roles/github_runner_arc/tasks/app_setup.yml @@ -1,31 +1,44 @@ --- -# Creates the GitHub App the runner scale sets authenticate as, through GitHub's App manifest flow, in two runs of playbooks/github_app_setup.yml on the control node. Without github_runner_arc_app_manifest_code it renders a local HTML form that posts the manifest to GitHub; a person submits it and copies the one-time code GitHub returns. With the code it exchanges it for the App's credentials, writes the private key to github_runner_arc_app_private_key_path, waits while the person installs the App on the organisation, and prints the values to record. With github_runner_arc_app_setup_write_secret it also writes the App Secret into the cluster and then removes the key file, unless github_runner_arc_app_setup_keep_private_key keeps it. The private key is never printed, including under -v. +# Creates the GitHub App the runner scale sets authenticate as, through GitHub's App manifest flow, in two runs of playbooks/github_app_setup.yml on the control node. The App is for one scope: an organisation's runners, or with github_runner_arc_app_setup_repository one repository's, of an organisation or (github_runner_arc_app_setup_owner_type user) a personal account. Without github_runner_arc_app_manifest_code it renders a local HTML form that posts the manifest to GitHub's App settings of that account; a person submits it and copies the one-time code GitHub returns. With the code it exchanges it for the App's credentials, writes the private key to github_runner_arc_app_private_key_path, waits while the person installs the App on the account (where it reaches the repository, for a repository scope), and prints the values to record. With github_runner_arc_app_setup_write_secret it also writes the App Secret into the cluster and then removes the key file, unless github_runner_arc_app_setup_keep_private_key keeps it. The private key is never printed, including under -v. -- name: Fail when the organisation or App name is missing +- name: Fail when the account or App name is missing ansible.builtin.fail: - msg: github_runner_arc_app_setup_org (the organisation login) and github_runner_arc_app_setup_name (the App's display name) are both required. + msg: github_runner_arc_app_setup_org (the account login) and github_runner_arc_app_setup_name (the App's display name) are both required. when: github_runner_arc_app_setup_org | length == 0 or github_runner_arc_app_setup_name | length == 0 -# Where the App Secret goes: explicit namespaces and name, or the organisation's own scale-set profiles in github_runner_arc_orgs, whose namespaces and app_secret_name the role's install uses. Worked out in both phases, so that a phase 1 run already shows a configuration phase 2 could not use. +- name: Fail when the App's scope cannot exist + ansible.builtin.fail: + msg: >- + {{ 'github_runner_arc_app_setup_owner_type must be organization or user.' if github_runner_arc_app_setup_owner_type not in ['organization', 'user'] + else 'github_runner_arc_app_setup_repository must be a repository name without its owner (no /).' if '/' in github_runner_arc_app_setup_repository + else 'A personal account has no organisation runners: with github_runner_arc_app_setup_owner_type user, set github_runner_arc_app_setup_repository to the repository whose runners the App is for.' }} + when: >- + github_runner_arc_app_setup_owner_type not in ['organization', 'user'] + or '/' in github_runner_arc_app_setup_repository + or (github_runner_arc_app_setup_owner_type == 'user' and github_runner_arc_app_setup_repository | length == 0) + +# Where the App Secret goes: explicit namespaces and name, or the scale-set profiles of the github_runner_arc_orgs entry for the App's scope, whose namespaces and app_secret_name the role's install uses. Worked out in both phases, so that a phase 1 run already shows a configuration phase 2 could not use. - name: Work out where the App Secret goes ansible.builtin.set_fact: github_runner_arc_app_setup_secret_targets: >- {{ (github_runner_arc_app_setup_secret_namespaces or github_runner_arc_app_setup_org_profiles | map(attribute='namespace') | list) - | product([github_runner_arc_app_setup_secret_name or (github_runner_arc_app_setup_org_profiles | map(attribute='app_secret') | first | default(github_runner_arc_app_setup_org | lower ~ '-github-app'))]) + | product([github_runner_arc_app_setup_secret_name or (github_runner_arc_app_setup_org_profiles | map(attribute='app_secret') | first + | default({'name': github_runner_arc_app_setup_org, 'repository': github_runner_arc_app_setup_repository} | exadev.github_runner.arc_default_app_secret))]) | list }} vars: - # The organisation's entry is expanded on its own, so its missing app_id (the App does not exist yet) makes arc_profiles report an error without hiding the profiles. + # The login's entries are expanded on their own, so a missing app_id (the App does not exist yet) makes arc_profiles report an error without hiding the profiles; the scope then picks the entry this App is for. github_runner_arc_app_setup_org_profiles: >- - {{ (github_runner_arc_orgs | selectattr('name', 'defined') | selectattr('name', 'equalto', github_runner_arc_app_setup_org) | list | exadev.github_runner.arc_profiles).profiles }} + {{ (github_runner_arc_orgs | selectattr('name', 'defined') | selectattr('name', 'equalto', github_runner_arc_app_setup_org) | list | exadev.github_runner.arc_profiles).profiles + | selectattr('scope', 'equalto', github_runner_arc_app_setup_scope) | list }} when: github_runner_arc_app_setup_write_secret | bool - name: Fail when there is nowhere to write the App Secret ansible.builtin.fail: msg: >- github_runner_arc_app_setup_write_secret needs the namespaces to write the App Secret into: set github_runner_arc_app_setup_secret_namespaces, - or configure {{ github_runner_arc_app_setup_org }} with its scale-set profiles in github_runner_arc_orgs. + or configure {{ github_runner_arc_app_setup_scope }} with its scale-set profiles in github_runner_arc_orgs. when: github_runner_arc_app_setup_write_secret | bool and github_runner_arc_app_setup_secret_targets | length == 0 - name: Build the App manifest @@ -40,7 +53,7 @@ hook_attributes: url: "{{ github_runner_arc_app_setup_homepage_url or 'https://github.com/' ~ github_runner_arc_app_setup_org }}" active: false - default_permissions: "{{ github_runner_arc_app_setup_permissions }}" + default_permissions: "{{ github_runner_arc_app_setup_scope_permissions }}" - name: Render the manifest form (phase 1) when: github_runner_arc_app_manifest_code | length == 0 @@ -65,7 +78,7 @@ ansible.builtin.debug: msg: - "Open {{ github_runner_arc_app_form.path }} in a browser and follow its steps." - - "It posts the manifest to GitHub for {{ github_runner_arc_app_setup_org }}; you must be an owner of that organisation." + - "{{ 'It posts the manifest to the App settings of your personal account; sign in to GitHub as ' ~ github_runner_arc_app_setup_org ~ '.' if github_runner_arc_app_setup_owner_type == 'user' else 'It posts the manifest to GitHub for ' ~ github_runner_arc_app_setup_org ~ '; you must be an owner of that organisation.' }}" - "Then run: {{ github_runner_arc_app_setup_command }} -e github_runner_arc_app_manifest_code= -e github_runner_arc_app_private_key_path=" - name: Exchange the code and wait for the installation (phase 2) @@ -159,9 +172,10 @@ ansible.builtin.debug: msg: - "App {{ github_runner_arc_app_setup_name }} (id {{ github_runner_arc_app_conversion.json.id }}) exists but is not installed." - - "Install it on {{ github_runner_arc_app_setup_org }}, for all repositories: {{ github_runner_arc_app_conversion.json.html_url }}/installations/new" + - "Install it on {{ github_runner_arc_app_setup_org }}, {{ 'with ' ~ github_runner_arc_app_setup_repository ~ ' among its repositories' if github_runner_arc_app_setup_repository | length > 0 else 'for all repositories' }}: {{ github_runner_arc_app_conversion.json.html_url }}/installations/new" - "Waiting up to {{ (github_runner_arc_app_setup_install_retries * github_runner_arc_app_setup_install_delay) // 60 }} minutes for the installation to appear." + # As resolve_installation_id.yml finds it: an organisation's installation by account login, a repository's through GET /repos/{owner}/{repo}/installation, which answers only once an installation reaches the repository. - name: Wait for the App's installation on the organisation ansible.builtin.uri: url: https://api.github.com/app/installations?per_page=100 @@ -174,10 +188,31 @@ retries: "{{ github_runner_arc_app_setup_install_retries }}" delay: "{{ github_runner_arc_app_setup_install_delay }}" no_log: true + when: github_runner_arc_app_setup_repository | length == 0 + + - name: Wait for the App's installation to reach the repository + ansible.builtin.uri: + url: "https://api.github.com/repos/{{ github_runner_arc_app_setup_org | urlencode }}/{{ github_runner_arc_app_setup_repository | urlencode }}/installation" + headers: + Authorization: "Bearer {{ github_runner_arc_app_jwt.stdout | trim }}" + Accept: application/vnd.github+json + X-GitHub-Api-Version: "2022-11-28" + status_code: [200, 404] + register: github_runner_arc_app_repository_installation + until: github_runner_arc_app_repository_installation.status == 200 + retries: "{{ github_runner_arc_app_setup_install_retries }}" + delay: "{{ github_runner_arc_app_setup_install_delay }}" + no_log: true + when: github_runner_arc_app_setup_repository | length > 0 - - name: Record the App's installation on the organisation + - name: Record the App's installation ansible.builtin.set_fact: - github_runner_arc_app_installation_id: "{{ (github_runner_arc_app_installations.json | selectattr('account.login', 'equalto', github_runner_arc_app_setup_org) | first).id | string }}" + github_runner_arc_app_installation_id: >- + {{ + (github_runner_arc_app_repository_installation.json.id + if github_runner_arc_app_setup_repository | length > 0 else + (github_runner_arc_app_installations.json | selectattr('account.login', 'equalto', github_runner_arc_app_setup_org) | first).id) | string + }} - name: Write the App Secret when: github_runner_arc_app_setup_write_secret | bool @@ -219,5 +254,6 @@ + (['Private key file removed; the App Secret holds the only copy.'] if github_runner_arc_app_setup_write_secret | bool and not (github_runner_arc_app_setup_keep_private_key | bool) else ['Private key written to ' ~ github_runner_arc_app_private_key_path ~ "; store it in your secret store and set the org's private_key from it (or its private_key_op_reference, with the 1Password adapter)."]) - + ['github_runner_arc_orgs entry: name ' ~ github_runner_arc_app_setup_org ~ ', app_id ' ~ github_runner_arc_app_conversion.json.id ~ ', installation_id ' ~ github_runner_arc_app_installation_id] + + ['github_runner_arc_orgs entry: name ' ~ github_runner_arc_app_setup_org + ~ (', repository ' ~ github_runner_arc_app_setup_repository if github_runner_arc_app_setup_repository | length > 0 else '') ~ ', app_id ' ~ github_runner_arc_app_conversion.json.id ~ ', installation_id ' ~ github_runner_arc_app_installation_id] }} diff --git a/roles/github_runner_arc/templates/github-app-manifest-form.html.j2 b/roles/github_runner_arc/templates/github-app-manifest-form.html.j2 index 8dfa8b1..c533100 100644 --- a/roles/github_runner_arc/templates/github-app-manifest-form.html.j2 +++ b/roles/github_runner_arc/templates/github-app-manifest-form.html.j2 @@ -14,7 +14,7 @@

Create the “{{ github_runner_arc_app_setup_name | e }}” GitHub App

-

This page submits a pre-filled App manifest to GitHub for the {{ github_runner_arc_app_setup_org | e }} organisation. The button below is its only network action, and it goes to github.com.

+

This page submits a pre-filled App manifest to GitHub for the {{ github_runner_arc_app_setup_org | e }} {{ 'personal account' if github_runner_arc_app_setup_owner_type == 'user' else 'organisation' }}, for the runners of {{ ('the ' ~ github_runner_arc_app_setup_scope ~ ' repository') | e if github_runner_arc_app_setup_repository | length > 0 else 'the organisation' }}. The button below is its only network action, and it goes to github.com.

Manifest

{{ github_runner_arc_app_manifest | to_nice_json | e }}
@@ -22,15 +22,16 @@

Steps

  1. Check the manifest above, especially its permissions.
  2. -
  3. Click “Create GitHub App”, signed in to GitHub as an owner of {{ github_runner_arc_app_setup_org | e }}, and confirm on GitHub's page.
  4. +
  5. Click “Create GitHub App”, signed in to GitHub as {{ github_runner_arc_app_setup_org | e if github_runner_arc_app_setup_owner_type == 'user' else 'an owner of ' ~ (github_runner_arc_app_setup_org | e) }}, and confirm on GitHub's page.
  6. GitHub then redirects to {{ github_runner_arc_app_manifest.redirect_url | e }} with a code query parameter. Copy its value from the address bar.
  7. Run the second phase straight away, because the code works once and expires quickly:
    {{ github_runner_arc_app_setup_command | e }} -e github_runner_arc_app_manifest_code=<code> -e github_runner_arc_app_private_key_path=<path for the key>
  8. -
  9. That run creates the App and waits while you install it on the organisation, at organisation level rather than on selected repositories.{% if github_runner_arc_app_setup_write_secret | bool %} It then writes the App Secret into the cluster{{ ' and removes the key file' if not (github_runner_arc_app_setup_keep_private_key | bool) else '' }}.{% endif %}
  10. +
  11. That run creates the App and waits while you install it on {{ github_runner_arc_app_setup_org | e }}, {{ ('choosing ' ~ github_runner_arc_app_setup_repository ~ ' among its repositories (only that one is enough)') | e if github_runner_arc_app_setup_repository | length > 0 else 'at organisation level rather than on selected repositories' }}.{% if github_runner_arc_app_setup_write_secret | bool %} It then writes the App Secret into the cluster{{ ' and removes the key file' if not (github_runner_arc_app_setup_keep_private_key | bool) else '' }}.{% endif %}
-
+{# GitHub's manifest flow takes a personal account's App at /settings/apps/new and an organisation's at /organizations//settings/apps/new (docs.github.com, Registering a GitHub App from a manifest). #} + diff --git a/roles/github_runner_arc/vars/main.yml b/roles/github_runner_arc/vars/main.yml index dec41ba..2bdd248 100644 --- a/roles/github_runner_arc/vars/main.yml +++ b/roles/github_runner_arc/vars/main.yml @@ -16,6 +16,16 @@ github_runner_arc_node_recovery_annotation: github-runner.exadev/out-of-service- # Whether the runner pods and the platform's Deployments reference a pull Secret at all. With github_runner_arc_image_pull_secret_source none every image they run is public, so the role writes, checks and references none, and removes one an earlier run wrote. github_runner_arc_pull_secret_in_use: "{{ github_runner_arc_image_pull_secret_source != 'none' }}" +# The permissions ARC documents for a GitHub App at each scope, which app_setup.yml's manifest asks for unless github_runner_arc_app_setup_permissions names its own: an organisation's runners need the organisation's self-hosted runners permission, and a repository's runners need repository administration, with metadata read (docs.github.com, Authenticating ARC to the GitHub API). Keys are the GitHub App permission names a manifest's default_permissions takes. +github_runner_arc_app_setup_scope_permissions: >- + {{ + github_runner_arc_app_setup_permissions + or ({'administration': 'write', 'metadata': 'read'} if github_runner_arc_app_setup_repository | length > 0 else {'organization_self_hosted_runners': 'write'}) + }} + +# The scope the App setup's App is for, as plugins/filter/arc.py's arc_scope gives a github_runner_arc_orgs entry's. +github_runner_arc_app_setup_scope: "{{ {'name': github_runner_arc_app_setup_org, 'repository': github_runner_arc_app_setup_repository} | exadev.github_runner.arc_scope }}" + # The values install_scale_set_profile.yml passes to Helm with --set for a profile, with `org` and `profile` set: the URL of the scope the scale set registers in (an organisation, or one repository; see plugins/filter/arc.py's arc_scope) and the App Secret, the scale set's name (the release name, whatever a values file says, since the name is the scale set's registration identity on GitHub and arc_cluster_profiles compares releases to refuse one registered from two clusters), the pull Secret (only while one is in use), the runner image, and the profile's scaleSetLabels one index at a time. --set rather than values for containers[] and the label list, since Helm replaces lists wholesale, so a values mapping naming containers would drop the values file's own command and resources for that container. github_runner_arc_scale_set_set_values: >- {{ diff --git a/tests/unit/test_app_setup.py b/tests/unit/test_app_setup.py index 3aaa375..7dae585 100644 --- a/tests/unit/test_app_setup.py +++ b/tests/unit/test_app_setup.py @@ -5,6 +5,7 @@ import html import json import os +import re import shutil import subprocess import tempfile @@ -50,6 +51,20 @@ def form_text(tasks: list[dict]) -> str: BASE = {"github_runner_arc_app_setup_org": "example-org", "github_runner_arc_app_setup_name": "Example runners"} +USER_REPOSITORY = {"github_runner_arc_app_setup_org": "example-user", "github_runner_arc_app_setup_owner_type": "user", "github_runner_arc_app_setup_repository": "example-repo", "github_runner_arc_app_setup_name": "Example runners"} + + +def form_action(text: str) -> str: + """Return where the rendered form posts the manifest.""" + match = re.search(r'
dict: + """Return the manifest phase 1 built.""" + [built] = [task for task in tasks if task["task"] == "Build the App manifest"] + return built["ansible_facts"]["github_runner_arc_app_manifest"] @unittest.skipIf(shutil.which("ansible-playbook") is None, "ansible-playbook is not installed") @@ -94,6 +109,58 @@ def test_explicit_namespaces_and_the_default_name(self) -> None: [targets] = [task for task in tasks if task["task"] == "Work out where the App Secret goes"] self.assertEqual(targets["ansible_facts"]["github_runner_arc_app_setup_secret_targets"], [["one", "example-org-github-app"], ["two", "example-org-github-app"]]) + def test_an_organisation_app_posts_to_the_organisation_with_organisation_runner_access(self) -> None: + result, tasks = run_phase_one(BASE) + self.assertEqual(result.returncode, 0, result.stdout[-2000:]) + self.assertTrue(form_action(form_text(tasks)).startswith("https://github.com/organizations/example-org/settings/apps/new?state=")) + self.assertEqual(manifest(tasks)["default_permissions"], {"organization_self_hosted_runners": "write"}) + + def test_a_personal_accounts_repository_app_posts_to_the_account_with_repository_access(self) -> None: + result, tasks = run_phase_one(USER_REPOSITORY) + self.assertEqual(result.returncode, 0, result.stdout[-2000:]) + text = form_text(tasks) + self.assertTrue(form_action(text).startswith("https://github.com/settings/apps/new?state=")) + self.assertEqual(manifest(tasks)["default_permissions"], {"administration": "write", "metadata": "read"}) + self.assertIn("signed in to GitHub as example-user,", text) + self.assertIn("choosing example-repo among its repositories", text) + self.assertIn("""ansible-playbook playbooks/github_app_setup.yml -e github_runner_arc_app_setup_org=example-user -e github_runner_arc_app_setup_owner_type=user -e github_runner_arc_app_setup_repository=example-repo -e '{"github_runner_arc_app_setup_name": "Example runners"}' -e github_runner_arc_app_manifest_code=""", text) + + def test_an_organisations_repository_app_posts_to_the_organisation_with_repository_access(self) -> None: + result, tasks = run_phase_one(BASE | {"github_runner_arc_app_setup_repository": "example-repo"}) + self.assertEqual(result.returncode, 0, result.stdout[-2000:]) + self.assertTrue(form_action(form_text(tasks)).startswith("https://github.com/organizations/example-org/settings/apps/new?state=")) + self.assertEqual(manifest(tasks)["default_permissions"], {"administration": "write", "metadata": "read"}) + + def test_explicit_permissions_replace_the_scopes_own(self) -> None: + result, tasks = run_phase_one(USER_REPOSITORY | {"github_runner_arc_app_setup_permissions": {"administration": "write", "metadata": "read", "actions": "read"}}) + self.assertEqual(result.returncode, 0, result.stdout[-2000:]) + form_text(tasks) + self.assertEqual(manifest(tasks)["default_permissions"], {"administration": "write", "metadata": "read", "actions": "read"}) + + def test_a_personal_account_without_a_repository_fails_before_the_form(self) -> None: + result, tasks = run_phase_one(USER_REPOSITORY | {"github_runner_arc_app_setup_repository": ""}) + self.assertNotEqual(result.returncode, 0) + [failure] = [task for task in tasks if task["task"] == "Fail when the App's scope cannot exist"] + self.assertIn("A personal account has no organisation runners", failure["msg"]) + self.assertFalse([task for task in tasks if task["task"] == "Render the form" and not task.get("skipped")]) + + def test_secret_targets_follow_the_repository_scope(self) -> None: + orgs = [ + {"name": "example-user", "image": "ghcr.io/example-user/runner:1", "scale_set_profiles": [{"max_runners": 1}]}, + {"name": "example-user", "repository": "example-repo", "image": "ghcr.io/example-user/runner:1", "scale_set_profiles": [{"max_runners": 1}, {"suffix": "-big", "max_runners": 1}]}, + ] + result, tasks = run_phase_one(USER_REPOSITORY | {"github_runner_arc_app_setup_write_secret": True, "github_runner_arc_orgs": orgs}) + self.assertEqual(result.returncode, 0, result.stdout[-2000:]) + form_text(tasks) + [targets] = [task for task in tasks if task["task"] == "Work out where the App Secret goes"] + self.assertEqual(targets["ansible_facts"]["github_runner_arc_app_setup_secret_targets"], [["arc-runners-example-user--example-repo", "example-user--example-repo-github-app"], ["arc-runners-example-user--example-repo-big", "example-user--example-repo-github-app"]]) + + def test_explicit_namespaces_take_the_repository_scopes_default_name(self) -> None: + _, tasks = run_phase_one(USER_REPOSITORY | {"github_runner_arc_app_setup_write_secret": True, "github_runner_arc_app_setup_secret_namespaces": ["one"]}) + form_text(tasks) + [targets] = [task for task in tasks if task["task"] == "Work out where the App Secret goes"] + self.assertEqual(targets["ansible_facts"]["github_runner_arc_app_setup_secret_targets"], [["one", "example-user--example-repo-github-app"]]) + def test_writing_the_secret_with_nowhere_to_write_it_fails_in_phase_one(self) -> None: result, tasks = run_phase_one(BASE | {"github_runner_arc_app_setup_write_secret": True}) self.assertNotEqual(result.returncode, 0) diff --git a/tests/unit/test_arc_filters.py b/tests/unit/test_arc_filters.py index e823719..f1ca324 100644 --- a/tests/unit/test_arc_filters.py +++ b/tests/unit/test_arc_filters.py @@ -285,6 +285,10 @@ def test_a_name_holding_a_repository_is_refused(self) -> None: result = arc.arc_profiles([org("example-user/example-repo")]) self.assertEqual(result["errors"], ["github_runner_arc_orgs[0].name must be an account login, without '/'; set repository for a repository's runners"]) + def test_the_default_app_secret_name_follows_the_scope(self) -> None: + self.assertEqual(arc.arc_default_app_secret(org("Example")), "example-github-app") + self.assertEqual(arc.arc_default_app_secret(org("example-user", repository="Example.Repo")), "example-user--example-repo-github-app") + def test_an_unset_or_null_repository_is_the_organisation_scope(self) -> None: self.assertEqual(arc.arc_scope(org("Example")), "Example") self.assertEqual(arc.arc_scope(org("Example", repository=None)), "Example") From 38d3a2d2a2773f8ffc468ba4cda0a77650a23421 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 25 Oct 2026 07:48:22 +0000 Subject: [PATCH 6/8] feat(bootstrap): register a bootstrapped scale set in one repository RUNNER_REPOSITORY sets the entry's repository, so a single machine can bring up runners for one repository of a user or an organisation. --- .env.bootstrap.example | 2 ++ bootstrap.sh | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.env.bootstrap.example b/.env.bootstrap.example index c685bf2..a1ec05a 100644 --- a/.env.bootstrap.example +++ b/.env.bootstrap.example @@ -37,6 +37,8 @@ GITHUB_RUNNER_COMPOSE_PROJECT= # The GitHub organisation the runners register with, and its GitHub App (see playbooks/github_app_setup.yml to create one). The installation ID is resolved automatically from the App ID and key when left empty. RUNNER_ORG= +# A repository of RUNNER_ORG to register the runners in instead of the organisation, which makes RUNNER_ORG a user or an organisation (see Repository scope in roles/github_runner_arc/README.md); its App needs the repository-scope permissions. Empty registers organisation runners. +RUNNER_REPOSITORY= RUNNER_APP_ID= RUNNER_APP_INSTALLATION_ID= RUNNER_APP_PRIVATE_KEY_PATH=./secrets/app-private-key.pem diff --git a/bootstrap.sh b/bootstrap.sh index 640da73..0541234 100755 --- a/bootstrap.sh +++ b/bootstrap.sh @@ -68,7 +68,7 @@ jq -n \ {suffix: "", autoscale: ((env.AUTOSCALER_USABLE_BUDGET_GI // "") != "")} + (if (env.RUNNER_VALUES_FILE // "") != "" then {values_file: env.RUNNER_VALUES_FILE} else {max_runners: env.RUNNER_MAX_RUNNERS} end) ] - }] + } + opt("repository"; "RUNNER_REPOSITORY")] end ), github_runner_arc_values_dir: $repo_root, From af236aa17ddfd5f16b46207eace722c26ff283ca Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 25 Oct 2026 07:48:24 +0000 Subject: [PATCH 7/8] docs(arc): describe repository-scoped entries and their App permissions Covers the repository key and its githubConfigUrl, the names it derives and why they never collide, the App permissions for each scope with their sources, and that every repository needs an entry of its own. --- README.md | 18 +++++----- roles/github_runner_arc/README.md | 60 ++++++++++++++++++++++++++----- 2 files changed, 61 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index e287e13..0e86fc9 100644 --- a/README.md +++ b/README.md @@ -8,11 +8,11 @@ There are two ways to deploy. A fleet's hosts are managed from a control machine Prerequisites on the host: Docker (or Colima) with the Compose plugin, any release but 2.37.1 to 2.38.x (the cluster role refuses those; see Docker Compose versions in `roles/github_runner_cluster/README.md`), `python3`, `jq`, `openssl`, and `gh`. On macOS, the playbook installs `helm`, `kubectl` and Docker through Homebrew itself, and the cluster role starts a Colima host's profile at every login and fails unless the runtime is up (Docker Desktop needs its own start-at-login setting; see Container runtime at login in `roles/github_runner_cluster/README.md`). Elsewhere, install `helm` and `kubectl` from the host's package manager first. -1. **Create a GitHub App** on the organisation with **Self-hosted runners: read & write** permission, and install it on the organisation. `playbooks/github_app_setup.yml` does this through GitHub's manifest flow (see `roles/github_runner_arc/README.md`). +1. **Create a GitHub App** on the organisation with **Self-hosted runners: read & write** permission, and install it on the organisation. `playbooks/github_app_setup.yml` does this through GitHub's manifest flow (see `roles/github_runner_arc/README.md`). For one repository's runners instead, which is how a repository of a personal account uses the fleet, the App needs repository **Administration: read & write** and **Metadata: read**, installed where it reaches that repository, and `RUNNER_REPOSITORY` names it (see Repository scope in `roles/github_runner_arc/README.md`). 2. **Configure the environment.** Copy `.env.bootstrap.example` to `.env.bootstrap` and fill in the following (the file's own comments cover the rest, such as joining an existing cluster or running as an agent): - `K3S_TOKEN`: any random string (`openssl rand -hex 32`). It authenticates node-join inside the cluster only; it has no relation to GitHub. - `K3S_VPN_AUTH_JOIN_KEY`, `K3S_TLS_SAN_LIST`: see [Tailscale](#tailscale) below. - - `RUNNER_ORG`, `RUNNER_APP_ID`, `RUNNER_APP_PRIVATE_KEY_PATH`: the organisation, the App ID and a path to its private key PEM file (for example under `./secrets/`, already gitignored). + - `RUNNER_ORG`, `RUNNER_APP_ID`, `RUNNER_APP_PRIVATE_KEY_PATH`: the organisation, the App ID and a path to its private key PEM file (for example under `./secrets/`, already gitignored). With `RUNNER_REPOSITORY` set, the runners register in that repository of `RUNNER_ORG`, which may then be a user. - `RUNNER_APP_INSTALLATION_ID`: optional. Leave it blank and the role resolves it automatically from the App ID and key. - `RUNNER_IMAGE`, and either `RUNNER_VALUES_FILE` (a Helm values template) or `RUNNER_MAX_RUNNERS` (the ARC role's own template with that many runners at most). - `GHCR_PULL_USERNAME`, `GHCR_PULL_TOKEN`: a registry credential, such as a GHCR personal access token with `read:packages` scope, to pull the runner image and the fleet-health platform's own images. @@ -39,7 +39,7 @@ The playbook runs up to four roles, each with its own README: `github_runner_clu Which hosts are k3s servers is worked out from the inventory, not set per host. The `github_runner_cluster` inventory group lists the cluster's hosts in order: the first N become servers (control-plane/etcd members running `k3s server`, genuine voting members of the raft quorum), where N is the largest odd number no greater than both the number of hosts and `github_runner_cluster_max_servers` (5 by default), so 1 or 2 hosts give 1 server, 3 or 4 give 3, and 5 or more give 5. The first server bootstraps the cluster (`--cluster-init`, embedded etcd) and every other host joins it, the rest as agents (`k3s agent`, schedulable capacity with no control-plane role). Join URLs and every server's `--tls-san` list are derived from each server's node name and `github_runner_cluster_tailnet_domain`. A host can still set `github_runner_cluster_node_role`, `github_runner_cluster_bootstrap`, `github_runner_cluster_server_url` or `github_runner_cluster_tls_sans` to override its part, and the run fails with an explanation if the result has an even number of servers, none at all, or more than one bootstrap host; `roles/github_runner_cluster/defaults/main.yml` describes each. Installing the ARC controller, each org's runner scale set, and the fleet-health platform is not tied to a node role at all: each is driven purely by whether the installing host's own `github_runner_arc_orgs`/`github_runner_arc_heartbeat_gist_id` is set, so any server host can be the one Ansible installs against, and moving which host does the installing is a config change (which host's `host_vars` sets it), not a role change. See the [Architecture](#architecture) section for why 3 servers is the minimum sane count for genuine HA (2 is worse than 1 for quorum math, not better). To add a server or agent, add the host to the `github_runner_cluster` group in the fleet's inventory and give it its own `host_vars/.yml`; keep the bootstrap host first. -`github_runner_arc_orgs[].scale_set_profiles` is a list of `{suffix, values_file?, node_selector?, runs_on_label?, capabilities?, autoscale?, sizing?, burst?}` entries (see `roles/github_runner_arc/README.md` for every key) - one Helm release and one namespace per entry, pooled under the org's own shared `runs-on` label via `scaleSetLabels` (not `runnerScaleSetName`, which is a scale set's own unique GitHub-side registration identity, not just a label - two releases sharing it collide outright) unless a profile sets its own `runs_on_label` to deliberately opt out of that pool - e.g. a dedicated image-building profile ordinary CI jobs have no business landing on. `node_selector` is optional per profile, not mandatory: omitting it leaves that profile's pods genuinely unpinned, letting the Kubernetes scheduler place them on whichever node in the fleet actually has room - real failover if one machine goes down, not just capacity pooling. Values files are rendered on the control node from `github_runner_arc_values_dir`, a directory in the fleet's own repository, so no fleet host needs them. ExaDev's own fleet uses two profiles: an ordinary CI profile unpinned across the whole fleet (sized with Guaranteed QoS for the smallest machine's real capacity), and a second, separately-labelled, Docker-in-Docker profile pinned to one host for building the runner image - a deliberate isolation choice, not a capacity constraint. +A `github_runner_arc_orgs` entry registers its scale sets in an organisation, or with `repository` in one repository of a user or an organisation (`githubConfigUrl` `https://github.com//`); a repository's scale sets take only that repository's jobs, so each repository needs its own entry (see Repository scope in `roles/github_runner_arc/README.md`). `github_runner_arc_orgs[].scale_set_profiles` is a list of `{suffix, values_file?, node_selector?, runs_on_label?, capabilities?, autoscale?, sizing?, burst?}` entries (see `roles/github_runner_arc/README.md` for every key) - one Helm release and one namespace per entry, pooled under the org's own shared `runs-on` label via `scaleSetLabels` (not `runnerScaleSetName`, which is a scale set's own unique GitHub-side registration identity, not just a label - two releases sharing it collide outright) unless a profile sets its own `runs_on_label` to deliberately opt out of that pool - e.g. a dedicated image-building profile ordinary CI jobs have no business landing on. `node_selector` is optional per profile, not mandatory: omitting it leaves that profile's pods genuinely unpinned, letting the Kubernetes scheduler place them on whichever node in the fleet actually has room - real failover if one machine goes down, not just capacity pooling. Values files are rendered on the control node from `github_runner_arc_values_dir`, a directory in the fleet's own repository, so no fleet host needs them. ExaDev's own fleet uses two profiles: an ordinary CI profile unpinned across the whole fleet (sized with Guaranteed QoS for the smallest machine's real capacity), and a second, separately-labelled, Docker-in-Docker profile pinned to one host for building the runner image - a deliberate isolation choice, not a capacity constraint. ### Unraid hosts @@ -57,7 +57,7 @@ Each Docker-in-Docker runner pod starts with an empty Docker daemon, so every jo - **Build the runner image locally (the only working path right now):** `docker buildx build --platform linux/arm64,linux/amd64 --push -t ghcr.io/exadev/github-runner:latest .` from a machine with buildx/QEMU cross-platform support (Docker Desktop has this built in). A plain `docker build` with no `--platform` only produces an image for the building machine's own architecture, which silently breaks scheduling on the fleet's other architecture. The fleet-health platform's own two images need the identical treatment, for the identical reason (they're genuinely unpinned pods now too - see [Heartbeat](#heartbeat)/[Autoscaler](#autoscaler)): `docker buildx build --platform linux/arm64,linux/amd64 --push -f heartbeat/Dockerfile -t ghcr.io/exadev/github-runner-heartbeat:latest .` and the same with `autoscaler/Dockerfile` → `ghcr.io/exadev/github-runner-autoscaler:latest`, `pull-secret-renewer/Dockerfile` → `ghcr.io/exadev/github-runner-pull-secret-renewer:latest` (the image of the ARC role's App-sourced pull Secret renewal), and `node-recovery/Dockerfile` → `ghcr.io/exadev/github-runner-node-recovery:latest` (see [Node recovery](#node-recovery)). - **Build and push via CI:** `.github/workflows/release.yml`'s `build-images` job builds and pushes all five images (this one, `heartbeat`, `autoscaler`, `pull-secret-renewer` and `node-recovery`) multi-arch on GitHub-hosted `ubuntu-latest` whenever a release is published - see [Releases](#releases) below. -- **Smoke-test the ARC scale set** (confirms a job gets a real ephemeral pod): `gh workflow run test-arc-runner.yml`, then watch `kubectl get pods -n arc-runners- -w`. A pod must appear only once the job is queued, run to completion, and be deleted within seconds. +- **Smoke-test the ARC scale set** (confirms a job gets a real ephemeral pod): `gh workflow run test-arc-runner.yml`, then watch `kubectl get pods -n arc-runners- -w` (`arc-runners---` for a repository entry). A pod must appear only once the job is queued, run to completion, and be deleted within seconds. - **Smoke-test `ubuntu-latest` routing** (confirms `runner-fallback-action` still reaches GitHub-hosted runners when needed): `gh workflow run test-ubuntu-latest.yml`. - **Generate load for the autoscaler** (see [Autoscaler](#autoscaler) below): `gh workflow run test-autoscaler.yml`. - **Verify cluster health:** @@ -193,12 +193,12 @@ Its image, `github_runner_arc_node_recovery_image`, is pulled without a pull Sec ## Conventions -Adding a second org is additive, never a change to anything existing: +Adding a second org, or a repository, is additive, never a change to anything existing: -1. Add a values file for the new org to the fleet's `github_runner_arc_values_dir` (an existing org's is a starting point), or leave `values_file` out of the profile and use the ARC role's own values template. -2. Add another entry to the installing host's `github_runner_arc_orgs` in its `host_vars/.yml`, with a `private_key` holding that org's App private key from any source Ansible reads, or a `private_key_op_reference` pointing at it in 1Password. +1. Add a values file for the new org or repository to the fleet's `github_runner_arc_values_dir` (an existing org's is a starting point), or leave `values_file` out of the profile and use the ARC role's own values template. +2. Add another entry to the installing host's `github_runner_arc_orgs` in its `host_vars/.yml` (with `repository` for one repository's runners), with a `private_key` holding that entry's App private key from any source Ansible reads, or a `private_key_op_reference` pointing at it in 1Password. -The controller install stays shared: it watches all namespaces by default, so one install serves every org's runner scale set release. No changes to the `Dockerfile`, the controller install, or any other org's release are needed to add an org. +The controller install stays shared: it watches all namespaces by default, so one install serves every org's and repository's runner scale set release. No changes to the `Dockerfile`, the controller install, or any other entry's release are needed to add one. The role must stay idempotent — every step uses `kubernetes.core.k8s` (server-side apply) or `helm upgrade --install` semantics, never a plain `create`, so reruns never fail on an already-existing resource. `bootstrap.sh` must stay a thin translation from `.env.bootstrap` to role variables: any deployment step added there instead of in the role reintroduces a second implementation that drifts. @@ -216,7 +216,7 @@ Commits follow Conventional Commits (`fix:`, `feat:`, `tune:`, and so on). - **The k3s node name must stay stable across container recreations.** Without a pinned `hostname`, k3s derives the node name from the container's hostname (its ID), so every recreation (for example on an image pull) registers a new node and leaves the old one stale in etcd. The cluster role's `docker-compose.yml` pins every server host's own hostname to `${K3S_NODE_NAME:-k3s-server}`, Ansible-templated per host (defaulting to that host's own inventory name; `github_runner_cluster_node_name` overrides it, for example to keep a pre-existing device identity rather than forcing a disruptive rename). - **`kubectl get nodes` can succeed with zero nodes.** It returns an empty, still-successful list before the node object has registered. The role polls for at least one node before calling `kubectl wait`, which fails outright ("no matching resources found") against zero nodes. - **Zero runner pods in an `arc-runners-` namespace (or its per-profile `-` variant) when idle is expected, not a bug** — `minRunners: 0` means pods exist only while a job is running. -- **An org's Settings > Actions > Runners page shows the runner scale set itself**, not individual long-lived runner names the way the previous design did. ARC's ephemeral runners do not persist between jobs, so there is nothing to list while idle. +- **An org's Settings > Actions > Runners page shows the runner scale set itself** (a repository entry's shows in that repository's own Settings > Actions > Runners), not individual long-lived runner names the way the previous design did. ARC's ephemeral runners do not persist between jobs, so there is nothing to list while idle. - **An autoscaled profile's static `maxRunners` and per-pod resource limits have to come from the pool's measured capacity**, not arbitrary defaults: a higher `maxRunners` without matching headroom leads to out-of-memory kills and pod evictions (ExaDev's fleet repository records the incident its own values were tuned from). `scripts/autoscaler.sh` depends on this value staying the safe floor it reverts to. - **The metrics API's pod list and `kubectl top nodes` require metrics-server.** k3s bundles it by default (the cluster role's `docker-compose.yml` only disables `traefik`), but confirm it is actually running during the first real autoscaler dry run — `scripts/autoscaler.sh` fails safe to the static floor if either is unreachable. A very short-lived job (seconds, not minutes) can complete and its pod be garbage-collected before metrics-server's own scrape interval ever captures its usage - confirmed live against `test-arc-runner.yml`'s ~15-20s jobs, where `kubectl top pod` genuinely never returns data. This correctly triggers the fail-safe rather than a wrong reading, and doesn't matter in practice: a job that short never accumulates meaningful memory pressure anyway. `test-autoscaler.yml`'s own probe jobs hold for 120s specifically so metrics-server has time to observe them. - **A replaced node can stay permanently `NotReady` with "Node password rejected" in its logs.** k3s keeps a node's password in `/etc/rancher/node/password` and the cluster holds a Secret for it (`kube-system/.node-password.k3s`). A recreated container keeps its password, because `/etc/rancher/node` is a named volume that the cluster role seeds from the running container before the first recreate that mounts it (see the role README's "Node passwords and recreating servers"), so this only happens to a node that is genuinely new under an old name: a replaced host, or a node whose volume was removed. Its container then generates a password the bootstrap host's Secret does not match, and the bootstrap host rejects the rejoin. Fix: on the bootstrap host, `kubectl delete secret .node-password.k3s -n kube-system` (and, for an agent, `kubectl delete node `), then restart the container so it registers fresh. Never delete a server's Node object: k3s then removes that server's etcd member, and a removed member's etcd refuses to start until its local datastore (`/var/lib/rancher/k3s/server/db`) is moved aside, after which it rejoins as a new member. `roles/github_runner_cluster/tasks/main.yml` self-heals the password mismatch automatically, only for hosts whose node is missing or not Ready, and deletes Node objects only for agents. diff --git a/roles/github_runner_arc/README.md b/roles/github_runner_arc/README.md index 9358940..7069050 100644 --- a/roles/github_runner_arc/README.md +++ b/roles/github_runner_arc/README.md @@ -14,9 +14,9 @@ Before touching the cluster the role then checks the secrets it is about to writ ## Variables -- `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 `-github-app`), and `scale_set_profiles`, a list of profiles: +- `github_runner_arc_orgs`: the organisations and repositories this host installs scale sets for. Each entry has `name`, optionally `repository` (see [Repository scope](#repository-scope): the entry's scale sets then register in that one repository of `name`, a user or an organisation, and `` in the names below stands for `--`), `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 entry's namespaces, default `-github-app`), and `scale_set_profiles`, a list of profiles: - `suffix`: appended to the namespace (`arc-runners-`) and release (`-runners`) 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. + - `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 scope (an organisation, or one repository) cannot share it, and Helm accepts at most 53 characters. - `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. @@ -381,15 +381,53 @@ The chart's own dind container cannot take an extra mount, so on a profile with With credentials configured, the cache pulls with them for anyone who reaches it: a job pulls whatever a configured credential can read without logging in, and the cache keeps those images on its volume. Give it read-only credentials scoped to what the runners should pull (a GitHub token with only `read:packages`, a Docker Hub personal access token with read-only access, a Quay robot account with read permission), never a credential that can push or administer. A NetworkPolicy admits only the scale sets' namespaces (every profile in the inventory) to the cache's ports, on a network plugin that enforces NetworkPolicy, as k3s's does; the traffic of the BuildKit containers dind starts leaves through the runner pod's own address, so it is admitted too. Every job in those namespaces shares the cache, so a fleet serving several organisations shares each credential's reach between them; give the cache credentials only for what every organisation on it may read. The cache is reached over plain HTTP inside the cluster, so no certificate authority is added to the runner pods and nothing intercepts their own TLS connections. The cache pod mounts no service account token. +## Repository scope + +An entry with `repository` registers its scale sets in that one repository rather than in an organisation: the role sets each release's `githubConfigUrl` to `https://github.com//`, the form ARC documents for repository runners ([Deploying runner scale sets with Actions Runner Controller](https://docs.github.com/en/actions/how-tos/manage-runners/use-actions-runner-controller/deploy-runner-scale-sets), and the chart's own [`values.yaml`](https://github.com/actions/actions-runner-controller/blob/master/charts/gha-runner-scale-set/values.yaml)). `name` may be a user or an organisation, so this is how a repository of a personal account, which has no organisation runners, uses the fleet. An entry without `repository` keeps registering organisation runners at `https://github.com/`. + +```yaml +github_runner_arc_orgs: + - name: example-org + app_id: 123456 + private_key: "{{ vault_example_org_app_private_key }}" + image: "ghcr.io/actions/actions-runner:2.338.0" + scale_set_profiles: + - max_runners: 4 + - name: example-user + repository: example-repo + app_id: 234567 + private_key: "{{ vault_example_user_app_private_key }}" + image: "ghcr.io/actions/actions-runner:2.338.0" + scale_set_profiles: + - max_runners: 2 +``` + +The names a repository entry derives join the login and the repository with a double hyphen, lower-cased, with every character a Kubernetes name cannot hold (`.`, `_`) turned into `-`: the entry above installs release `example-user--example-repo-runners` into namespace `arc-runners-example-user--example-repo`, with App Secret `example-user--example-repo-github-app` and the `runs-on` label `example-user--example-repo-runners`. GitHub refuses consecutive hyphens in a login, so these never match the names of an organisation entry of the same login, and two repositories of one login differ by their repository part. Two repository names that differ only in such characters (`example.repo` and `example_repo`) derive the same names, which validation refuses; give one profile its own `namespace` and `release_name`. A long repository name can push the derived release name past Helm's 53 characters, which validation also refuses, with the same remedy. + +The scope (`name`, or `name/repository`) identifies an entry. Validation refuses the same scope twice, in any letter case, whether on one host or on two hosts of one cluster, and checks release names and cross-cluster registrations per scope, since GitHub keeps an organisation's scale set names apart from a repository's. The role records each entry's installation id and App-minted pull token under its scope, so an organisation's entry and any number of its repositories' entries can sit side by side, each with its own App or sharing one. With the `github_runner_secrets_onepassword` adapter each `private_key_op_reference` is read once, whichever entries name it. + +A repository's scale set takes jobs from that repository only, so every repository that should use the fleet needs an entry of its own, with its own releases, its own listener in the controller's namespace and its own App Secret in each of its namespaces. Its runners' `runs-on` label serves that repository alone, unlike an organisation entry's pooled label. An autoscaled repository profile joins the one pooled memory budget every autoscaled profile shares (see the repository README's Autoscaler section); nothing about the pool is per scope. + +The entry's GitHub App needs the permissions ARC documents for the repository scope ([Authenticating ARC to the GitHub API](https://docs.github.com/en/actions/how-tos/manage-runners/use-actions-runner-controller/authenticate-to-the-api)): + +| Scope | Permissions | +| --- | --- | +| Organisation (no `repository`) | Organisation permission **Self-hosted runners**: Read and write. | +| Repository | Repository permissions **Administration**: Read and write, and **Metadata**: Read-only. Administration is needed only at the repository scope. | +| Either, with the runner reaper | Also repository permission **Actions**: Read. | +| Either, with `github_runner_arc_image_pull_secret_source: app` | Also **Packages**: Read. | + +The App must be installed on `name` with `repository` among the repositories it can reach. When the role resolves the installation id (`github_runner_arc_manage_secrets` with no `installation_id` set) it asks GitHub for the repository's own installation (`GET /repos/{owner}/{repo}/installation`), so an App installed on the account but not on that repository fails the run then, rather than as a listener that cannot register. The [setup flow](#github-app-setup) creates such an App with `github_runner_arc_app_setup_repository`. Everything else works as for an organisation, `github_runner_arc_manage_secrets: false` included: the role then reads the App's id from the existing App Secret in each of the entry's namespaces under its `app_secret_name` or derived name. The runner reaper reads each run from the repository ARC recorded on the runner, which for a repository scale set is always that repository. + ## Image pull Secret from the GitHub App -When the runner image is a private package owned by the org, `github_runner_arc_image_pull_secret_source: app` pulls it with the runners' own GitHub App rather than a registry token tied to a person. The App needs the `packages: read` permission (add it to `github_runner_arc_app_setup_permissions` when creating the App, or to an existing App's settings), approved by the organisation on the App's installation. +When the runner image is a private package owned by the account the App is installed on, `github_runner_arc_image_pull_secret_source: app` pulls it with the runners' own GitHub App rather than a registry token tied to a person. The App needs the `packages: read` permission (add it to `github_runner_arc_app_setup_permissions` when creating the App, or to an existing App's settings), approved by the account on the App's installation. A repository entry of a personal account uses an App installed on that user; whether its installation token reads the user's own private packages is not covered by the GitHub documentation this role cites, so use `static`, or `none` with public images, until a run proves it. -For each org the role signs an App JWT with the App's private key (the org's `private_key`, or the existing App Secret when `github_runner_arc_manage_secrets` is `false`), mints an installation token restricted to `packages: read`, and proves it can read the org's image with the registry's own token exchange and a manifest read, all before changing anything. It then writes the token into each of the org's namespaces as the pull Secret, with `x-access-token` as the username and the token's expiry in the `github-runner.exadev/pull-token-expires-at` annotation. +For each entry the role signs an App JWT with the App's private key (the entry's `private_key`, or the existing App Secret when `github_runner_arc_manage_secrets` is `false`), mints an installation token restricted to `packages: read`, and proves it can read the entry's image with the registry's own token exchange and a manifest read, all before changing anything. It then writes the token into each of the entry's namespaces as the pull Secret, with `x-access-token` as the username and the token's expiry in the `github-runner.exadev/pull-token-expires-at` annotation. An installation token lasts an hour, so the role also installs a CronJob in each of those namespaces, named after the pull Secret with `-renewer` appended, that repeats the mint and the check on `github_runner_arc_image_pull_secret_renewal_schedule` and patches the Secret only when both succeed; a refused run leaves the current Secret in place and fails the Job. It reads the App Secret from a mounted volume, and its Role allows only `get` and `patch` on the pull Secret by name. Its image, `github_runner_arc_image_pull_secret_renewer_image`, is pulled without a pull Secret, so an expired token never stops the job that replaces it, and the image must be public. With `static` the role removes any such CronJob and its RBAC. -The fleet-health platform belongs to no one org, so with `app` its Deployments pull without a pull Secret; the role's default platform images are public. +The fleet-health platform belongs to no one account, so with `app` its Deployments pull without a pull Secret; the role's default platform images are public. ## Public images without a pull Secret @@ -434,7 +472,7 @@ Its safety properties: - A runner is deleted only on a positive answer from GitHub. When the token cannot be minted (GitHub unreachable, the App Secret unusable or the permission missing) or the runners cannot be listed, the pass deletes nothing and its Job fails with the reason. When one runner's run or jobs cannot be read, that runner is left alone, the rest are still judged, and the Job fails at the end. A failed pass is not retried; the next scheduled one tries again. - Its Role allows `list` and `delete` on `ephemeralrunners` in its own namespace and nothing else. The App Secret reaches the pod as a volume, the token never appears in a command's arguments or the log, and the pod runs as a non-root user with a read-only root filesystem. -The reaper needs the org's GitHub App to have the **Actions** repository permission set to **Read**, approved on its installation, and the installation must include every repository whose jobs the runners take. The App the role's [setup flow](#github-app-setup) creates asks only for organisation self-hosted runners (write), so add `actions: read` to `github_runner_arc_app_setup_permissions` for a new App, or add the permission to an existing App in its settings and have an organisation owner approve it. Until then every pass fails saying the App needs the `actions: read` permission, and deletes nothing; a run in a repository the installation does not include is reported as unreadable, and its runner is left alone. +The reaper needs the entry's GitHub App to have the **Actions** repository permission set to **Read**, approved on its installation, and the installation must include every repository whose jobs the runners take (for a [repository entry](#repository-scope), that one repository). The App the role's [setup flow](#github-app-setup) creates asks only for the scope's runner permissions, so add `actions: read` to `github_runner_arc_app_setup_permissions` for a new App (beside the scope's own, which a value replaces), or add the permission to an existing App in its settings and have the account's owner approve it. The reaper calls only repository endpoints (`/repos/{owner}/{repo}/actions/runs/...`), so it works the same for either scope. Until then every pass fails saying the App needs the `actions: read` permission, and deletes nothing; a run in a repository the installation does not include is reported as unreadable, and its runner is left alone. The script (`files/reap-runners.sh`, with the token minting it shares with the pull Secret renewal) is mounted from a ConfigMap and runs in the renewal's image, which already has bash, curl, jq, kubectl and openssl, so a change to it reaches the cluster on the next run of the role without a new image. Its image is pulled without a pull Secret, so it must be public. `github_runner_arc_runner_reaper_enabled: false` removes the CronJob, its ConfigMap and its RBAC. @@ -442,15 +480,21 @@ ARC keeps the pod of a runner that GitHub still reports as running a job, even o ## GitHub App setup -`playbooks/github_app_setup.yml` creates the App the scale sets authenticate as, through GitHub's manifest flow. The first run renders a local form that posts the manifest (organisation self-hosted runner write access, no webhook, private) to GitHub; an organisation owner submits it and copies the one-time code GitHub returns. The second run, with that code and a path for the key, exchanges the code, writes the private key there, waits while the App is installed on the organisation, and prints the `app_id` and `installation_id` to record. +`playbooks/github_app_setup.yml` creates the App the scale sets authenticate as, through GitHub's manifest flow. The first run renders a local form that posts the manifest (the scope's permissions, no webhook, private) to GitHub; the account's owner submits it and copies the one-time code GitHub returns. The second run, with that code and a path for the key, exchanges the code, writes the private key there, waits while the App is installed, and prints the `app_id` and `installation_id` to record. + +The App is for one scope. By default it is an organisation's (`github_runner_arc_app_setup_org`), asking for organisation self-hosted runners write access, posted to the organisation's App settings and installed on the whole organisation. With `github_runner_arc_app_setup_repository` it is for that repository's runners (a [repository entry](#repository-scope)), asking for repository administration write and metadata read; the second run then waits until GitHub reports an installation that reaches that repository. A personal account's repository also needs `github_runner_arc_app_setup_owner_type: user`, which posts the manifest to the account's own App settings (`https://github.com/settings/apps/new`, where [GitHub's manifest flow](https://docs.github.com/en/apps/sharing-github-apps/registering-a-github-app-from-a-manifest) registers a personal account's App, rather than `https://github.com/organizations//settings/apps/new`); `user` without a repository fails before the form, since a personal account has no organisation runners. `github_runner_arc_app_setup_permissions` replaces the scope's permissions whole when set. ```sh ansible-playbook playbooks/github_app_setup.yml -e github_runner_arc_app_setup_org=example-org -e '{"github_runner_arc_app_setup_name": "Example runners"}' ansible-playbook playbooks/github_app_setup.yml -e github_runner_arc_app_setup_org=example-org -e '{"github_runner_arc_app_setup_name": "Example runners"}' \ -e github_runner_arc_app_manifest_code= -e github_runner_arc_app_private_key_path=~/example-app.pem + +# An App for one repository of a personal account +ansible-playbook playbooks/github_app_setup.yml -e github_runner_arc_app_setup_org=example-user -e github_runner_arc_app_setup_owner_type=user \ + -e github_runner_arc_app_setup_repository=example-repo -e '{"github_runner_arc_app_setup_name": "Example repository runners"}' ``` -With `github_runner_arc_app_setup_write_secret: true`, the second run also writes the App Secret, with the `github_app_id`, `github_app_installation_id` and `github_app_private_key` keys the role's install reads, into the cluster `github_runner_arc_kubeconfig_path` reaches. It goes into `github_runner_arc_app_setup_secret_namespaces`, or by default each namespace of the organisation's scale-set profiles in `github_runner_arc_orgs`, under `github_runner_arc_app_setup_secret_name`, or by default the organisation's `app_secret_name` (`-github-app` unless set). The run reaches the cluster and creates those namespaces before it uses the one-time code, so a broken cluster path costs only a rerun. Once the Secret is written it removes the key file, unless `github_runner_arc_app_setup_keep_private_key` keeps it; a run that stops earlier, for example because nobody installed the App within the wait, leaves the file so the App can still be finished without a new code. The private key is never printed, even with `-v`. The role's install then needs no `private_key` for that organisation: set `github_runner_arc_manage_secrets: false` so it uses the Secret as it is. +With `github_runner_arc_app_setup_write_secret: true`, the second run also writes the App Secret, with the `github_app_id`, `github_app_installation_id` and `github_app_private_key` keys the role's install reads, into the cluster `github_runner_arc_kubeconfig_path` reaches. It goes into `github_runner_arc_app_setup_secret_namespaces`, or by default each namespace of the scale-set profiles of the `github_runner_arc_orgs` entry for the App's scope, under `github_runner_arc_app_setup_secret_name`, or by default that entry's `app_secret_name` (`-github-app`, or `---github-app` for a repository, unless set). The run reaches the cluster and creates those namespaces before it uses the one-time code, so a broken cluster path costs only a rerun. Once the Secret is written it removes the key file, unless `github_runner_arc_app_setup_keep_private_key` keeps it; a run that stops earlier, for example because nobody installed the App within the wait, leaves the file so the App can still be finished without a new code. The private key is never printed, even with `-v`. The role's install then needs no `private_key` for that entry: set `github_runner_arc_manage_secrets: false` so it uses the Secret as it is. A playbook that wraps this one, for example to set the organisation and App name from its own variables, sets `github_runner_arc_app_setup_command` to the command that runs the wrapper, so the form and the first run's instructions name it: From 45c90de0a817a0c780b80ba3728ff1e675ed8b97 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 25 Oct 2026 07:49:19 +0000 Subject: [PATCH 8/8] docs(arc): state that the heartbeat expects repository scale sets too --- roles/github_runner_arc/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/roles/github_runner_arc/README.md b/roles/github_runner_arc/README.md index 7069050..950ee9f 100644 --- a/roles/github_runner_arc/README.md +++ b/roles/github_runner_arc/README.md @@ -406,7 +406,7 @@ The names a repository entry derives join the login and the repository with a do The scope (`name`, or `name/repository`) identifies an entry. Validation refuses the same scope twice, in any letter case, whether on one host or on two hosts of one cluster, and checks release names and cross-cluster registrations per scope, since GitHub keeps an organisation's scale set names apart from a repository's. The role records each entry's installation id and App-minted pull token under its scope, so an organisation's entry and any number of its repositories' entries can sit side by side, each with its own App or sharing one. With the `github_runner_secrets_onepassword` adapter each `private_key_op_reference` is read once, whichever entries name it. -A repository's scale set takes jobs from that repository only, so every repository that should use the fleet needs an entry of its own, with its own releases, its own listener in the controller's namespace and its own App Secret in each of its namespaces. Its runners' `runs-on` label serves that repository alone, unlike an organisation entry's pooled label. An autoscaled repository profile joins the one pooled memory budget every autoscaled profile shares (see the repository README's Autoscaler section); nothing about the pool is per scope. +A repository's scale set takes jobs from that repository only, so every repository that should use the fleet needs an entry of its own, with its own releases, its own listener in the controller's namespace and its own App Secret in each of its namespaces. Its runners' `runs-on` label serves that repository alone, unlike an organisation entry's pooled label. An autoscaled repository profile joins the one pooled memory budget every autoscaled profile shares (see the repository README's Autoscaler section); nothing about the pool is per scope. Likewise the heartbeat counts a repository's scale sets among those it expects (each must have a running listener before it vouches for the fleet), and anything reading its gist sees one fleet, not one health per scope. The entry's GitHub App needs the permissions ARC documents for the repository scope ([Authenticating ARC to the GitHub API](https://docs.github.com/en/actions/how-tos/manage-runners/use-actions-runner-controller/authenticate-to-the-api)):