From 6851ba75a6ebbfbbc0dbfc9580aa32c9aa1275e4 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Wed, 21 Oct 2026 06:38:32 +0100 Subject: [PATCH 1/2] fix(arc): check orgs per cluster and refuse a scale set registered from two clusters The cross-host checks compared every host in the inventory, so an org whose scale sets were split between two clusters was refused as a duplicate. They now compare the hosts of this host's cluster group, which share a controller, and compare other clusters' hosts only for the scale set names GitHub gives to one controller at a time. A host outside its cluster group still counts the whole inventory as one cluster. Fixes #130 --- plugins/filter/arc.py | 60 +++++++++++++++++++++- roles/github_runner_arc/README.md | 6 ++- roles/github_runner_arc/tasks/validate.yml | 17 +++--- roles/github_runner_cluster/README.md | 2 +- tests/unit/test_arc_filters.py | 50 ++++++++++++++++++ tests/unit/test_fleet_orgs.py | 19 +++++++ 6 files changed, 142 insertions(+), 12 deletions(-) diff --git a/plugins/filter/arc.py b/plugins/filter/arc.py index b78fedbe..a79e82f7 100644 --- a/plugins/filter/arc.py +++ b/plugins/filter/arc.py @@ -609,7 +609,7 @@ def _expand_profile(org_name: str, app_secret: str, index: int, profile: Any, er 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 configured anywhere in the inventory, not one host's, so that the cross-host checks (an org configured twice, two profiles sharing a namespace, more than one autoscaled profile setting its own sizing) see the whole fleet. + 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. 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. @@ -640,7 +640,7 @@ def arc_profiles(orgs: Sequence[Any], require_app_id: bool = True) -> dict[str, 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 across the inventory (also as '{seen_orgs[org_name.lower()]}'). Each org's Helm releases must be installed from exactly one host; remove the duplicate.") + 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.") continue seen_orgs[org_name.lower()] = org_name org_profiles = org.get("scale_set_profiles") @@ -671,6 +671,61 @@ def arc_profiles(orgs: Sequence[Any], require_app_id: bool = True) -> dict[str, return {"errors": errors, "profiles": profiles, "autoscaled": autoscaled} +def _is_list(value: Any) -> bool: + """Return whether value is a list in the YAML sense: a sequence that is not a string.""" + return isinstance(value, Sequence) and not isinstance(value, (str, bytes)) + + +def arc_cluster_profiles(orgs_by_host: Mapping[str, Any], cluster: Sequence[str] | None = None) -> dict[str, Any]: + """Expand and check the orgs of every host that installs into one cluster, and check their scale sets against the hosts of every other cluster. + + The hosts of one cluster share its controller, namespaces and autoscaled pool, so an org is installed into a cluster from one host, and arc_profiles' checks apply across all of them. A host of another cluster (another cluster group, with a controller of its own) may install the same org, since each cluster's releases and namespaces are its own, but not the same scale set: GitHub gives an org's scale set name to one controller at a time, so a second cluster's listener would contend with the first for it. + + Args: + orgs_by_host: each inventory host's github_runner_arc_orgs, by host name, in inventory order. cluster: the names of the hosts that install into the same cluster as the host being checked, or None when that is not known, which treats every host as one cluster. + + Returns: + arc_profiles' result for the orgs of the cluster's hosts (see arc_profiles; app_id is not required, since only each host's own check knows whether it writes the App Secret), with errors that also name an org configured on two of the cluster's hosts and a scale set registered from both this cluster and another. + """ + members = set(orgs_by_host) if cluster is None else set(cluster) + errors: list[str] = [] + cluster_orgs: list[Any] = [] + installers: dict[str, tuple[str, str]] = {} + for host, orgs in orgs_by_host.items(): + if host not in members: + continue + if not _is_list(orgs): + 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 "" + 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. + 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.") + continue + if name: + installers.setdefault(name.lower(), (host, name)) + cluster_orgs.append(org) + result = arc_profiles(cluster_orgs, require_app_id=False) + # Another cluster's hosts are checked in full by that cluster's own validation, so only the scale sets that expand are compared here. + registered: dict[tuple[str, str], tuple[str, str]] = {} + elsewhere: list[tuple[str, dict[str, Any]]] = [] + for host, orgs in orgs_by_host.items(): + if not _is_list(orgs): + 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"])) + else: + elsewhere.append((host, profile)) + for host, profile in elsewhere: + match = registered.get((profile["org_name"].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.") + return {**result, "errors": errors + result["errors"]} + + def _pinned_image_errors(where: str, image: Any) -> list[str]: """Return what is wrong with a capability's image reference: it must be a non-empty reference pinned to a version tag or a digest.""" if not isinstance(image, str) or not image: @@ -1458,6 +1513,7 @@ def filters(self) -> dict[str, Any]: """Return the filters this plugin provides.""" return { "arc_profiles": arc_profiles, + "arc_cluster_profiles": arc_cluster_profiles, "arc_max_runners": arc_max_runners, "arc_measured_max_runners": arc_measured_max_runners, "arc_upgrade_blocker": arc_upgrade_blocker, diff --git a/roles/github_runner_arc/README.md b/roles/github_runner_arc/README.md index ef949b8a..0e863a9c 100644 --- a/roles/github_runner_arc/README.md +++ b/roles/github_runner_arc/README.md @@ -6,7 +6,9 @@ It runs in two ways. Inside `playbooks/site.yml`, against a k3s server host that ## Validation -`tasks/validate.yml` checks every input that needs neither secrets nor a cluster (a measured sizing's node figures are read later, when the role installs the profile): each org's fields, profile names (derived or overridden) that would collide or make invalid Kubernetes names, two of an org's profiles sharing a release name, a `max_runners` that is not a whole number, the controller's release name and namespace, the same org configured on two hosts, more than one autoscaled profile, values files that do not exist, the autoscaler's pool settings, sizing inputs, burst settings, and the controller, probe and node label settings. Both playbooks run it in a play of its own before anything changes; the role runs it itself when included some other way. It also bootstraps the heartbeat gist when `github_runner_arc_heartbeat_bootstrap_gist` is on and no host in the inventory has one: it creates the gist with the control node's `gh` session and stops, printing the id to record. +`tasks/validate.yml` checks every input that needs neither secrets nor a cluster (a measured sizing's node figures are read later, when the role installs the profile): each org's fields, profile names (derived or overridden) that would collide or make invalid Kubernetes names, two of an org's profiles sharing a release name, a `max_runners` that is not a whole number, the controller's release name and namespace, the same org configured on two hosts of one cluster, a scale set registered from two clusters (see below), more than one autoscaled profile, values files that do not exist, the autoscaler's pool settings, sizing inputs, burst settings, and the controller, probe and node label settings. Both playbooks run it in a play of its own before anything changes; the role runs it itself when included some other way. It also bootstraps the heartbeat gist when `github_runner_arc_heartbeat_bootstrap_gist` is on and no host in the inventory has one: it creates the gist with the control node's `gh` session and stops, printing the id to record. + +The checks that span hosts compare the hosts of one cluster: the hosts in this host's cluster group (`github_runner_cluster_group`), which share a controller, its namespaces and the autoscaled pool, so an org is installed into a cluster from one host. A host of another cluster group may install the same org, for example while its scale sets move one at a time to a second cluster beside the first, but not the same scale set, since GitHub gives an org's scale set name to one controller at a time. A host outside its cluster group, such as the control node running `playbooks/arc.yml`, counts every host in the inventory as one cluster. Before touching the cluster the role then checks the secrets it is about to write (each org's App key looks like a PEM key, the pull credential and heartbeat token are set) and proves the pull credential can read every image it installs from `github_runner_arc_image_pull_registry`, by getting a pull-scoped registry token and fetching each image's manifest. With an App-sourced pull Secret (see below) it mints each org's token and proves it the same way, before installing anything. @@ -21,7 +23,7 @@ Before touching the cluster the role then checks the secrets it is about to writ - `runs_on_label`: the extra `scaleSetLabels` entry the runners carry. Default `-runners`, which pools every profile of the org under one `runs-on` label; set a different one to keep a profile out of that pool. - `scale_set_labels`: the whole `scaleSetLabels` list, in place of `runs_on_label`. An empty list sets no `scaleSetLabels`, so jobs target the scale set by its release name. - `tolerations`: Kubernetes tolerations for the profile's runner pods, for nodes that carry both the eligibility label and a `NoSchedule` or `NoExecute` taint that keeps other workloads off them, such as machines the controller and listener must not run on. Measured sizing counts a node whose only repelling taints these tolerations match; without them it leaves a tainted node out, because it takes runner pods to tolerate nothing. A taint Kubernetes adds for a passing condition (not ready, unreachable, cordoned) still counts as before. With `burst` they come before `burst.tolerations`. - - `autoscale`: `true` on at most one profile in the whole inventory makes it the scale set the autoscaler manages. + - `autoscale`: `true` makes the profile's scale set one of those the autoscaler manages, pooled against one memory budget; at most one autoscaled profile in a cluster may set `sizing`. - `sizing`: derive a runner ceiling from node capacity, given as uniform figures or measured from each eligible node (see Sizing). On an ordinary profile without `max_runners` it sets `maxRunners`; on the autoscaled profile it bounds the ceiling the autoscaler works out from the nodes, leaving `maxRunners` as the floor. - `capabilities`: names of entries in `github_runner_arc_capabilities` that the profile's runner pods carry, in order (see [Capabilities](#capabilities)). - `burst`: let the runner pods overflow onto nodes a cluster autoscaler adds on demand, and count those nodes' runners into `maxRunners` (see [Burst nodes](#burst-nodes)). Needs `sizing`, and no `max_runners` or `autoscale`; with `exclusive: true` the pods run on burst nodes only and `burst.max_runners` is the whole ceiling, with no `sizing`. diff --git a/roles/github_runner_arc/tasks/validate.yml b/roles/github_runner_arc/tasks/validate.yml index 1dce2c12..1b443d3e 100644 --- a/roles/github_runner_arc/tasks/validate.yml +++ b/roles/github_runner_arc/tasks/validate.yml @@ -1,17 +1,20 @@ --- # Checks every ARC input that can be checked without secrets or a cluster, so that playbooks/site.yml can run it in a pre-flight play before k3s is started or changed. Reads nothing from the target host and changes nothing, apart from bootstrapping the heartbeat gist on GitHub (see below). Cross-host checks read github_runner_arc_orgs from every host in the inventory, which --limit does not narrow, and from this host however it was set. -# This host's orgs come from github_runner_arc_orgs as this play resolves it, which includes play and role variables and -e; another host's can only be read from hostvars, which holds its inventory variables and facts. Taking this host's from hostvars too would miss orgs passed as a play variable, and every check and lookup built on the fleet (the scale sets' namespaces that measured sizing leaves out, the autoscaled profile) would then not see them. An implicit localhost is not in groups['all'] at all. -- name: Expand every org configured anywhere in the inventory into its scale-set profiles +# This host's orgs come from github_runner_arc_orgs as this play resolves it, which includes play and role variables and -e; another host's can only be read from hostvars, which holds its inventory variables and facts. Taking this host's from hostvars too would miss orgs passed as a play variable, and every check and lookup built on the fleet (the scale sets' namespaces that measured sizing leaves out, the autoscaled profile) would then not see them. An implicit localhost is not in groups['all'] at all. The fleet is this host's cluster: the hosts of its cluster group (github_runner_cluster_group, as the playbooks resolve it), each of which belongs to one cluster. Another cluster's hosts have a controller of their own, so they are compared only for scale set names, which GitHub gives to one controller at a time. A host outside its cluster group, such as the control node running playbooks/arc.yml, cannot tell which hosts share its cluster, so every host counts as one cluster. +- name: Expand every org configured in this host's cluster into its scale-set profiles ansible.builtin.set_fact: - # Whether an org needs app_id depends on whether its host writes the App Secret, which only that host's own check knows, so the fleet-wide expansion (for the cross-host checks) does not ask for it. + # Whether an org needs app_id depends on whether its host writes the App Secret, which only that host's own check knows, so the cluster-wide expansion (for the cross-host checks) does not ask for it. github_runner_arc_fleet: >- {{ - ((groups['all'] | reject('equalto', inventory_hostname) | map('extract', hostvars) | map(attribute='github_runner_arc_orgs', default=[]) | flatten) - + github_runner_arc_orgs) - | exadev.github_runner.arc_profiles(require_app_id=false) + dict(github_runner_arc_other_hosts | zip(github_runner_arc_other_hosts | map('extract', hostvars) | map(attribute='github_runner_arc_orgs', default=[]))) + | combine({inventory_hostname: github_runner_arc_orgs}) + | exadev.github_runner.arc_cluster_profiles(groups[github_runner_arc_cluster_group] if inventory_hostname in groups.get(github_runner_arc_cluster_group, []) else none) }} github_runner_arc_local: "{{ github_runner_arc_orgs | exadev.github_runner.arc_profiles(require_app_id=github_runner_arc_manage_secrets | bool) }}" + vars: + github_runner_arc_other_hosts: "{{ groups['all'] | reject('equalto', inventory_hostname) | list }}" + github_runner_arc_cluster_group: "{{ github_runner_cluster_group | default('github_runner_cluster') }}" - name: Fail on invalid orgs or scale-set profiles ansible.builtin.fail: @@ -151,7 +154,7 @@ label: "{{ item.msg }}" when: item.failed | bool -# The autoscaler runs from whichever host installs the platform, which need not be the host that installs the autoscaled profile, so these are checked on the platform host against the whole fleet's profiles. +# The autoscaler runs from whichever host installs the platform, which need not be the host that installs the autoscaled profile, so these are checked on the platform host against the profiles of every host in its cluster. - name: Fail when the autoscaler's pool settings are missing or inconsistent ansible.builtin.fail: msg: >- diff --git a/roles/github_runner_cluster/README.md b/roles/github_runner_cluster/README.md index 31de6292..dbb49dcb 100644 --- a/roles/github_runner_cluster/README.md +++ b/roles/github_runner_cluster/README.md @@ -240,7 +240,7 @@ A machine can run a node of a second, independent cluster beside a node of the f Before it writes anything to a host the role reads every container on its daemon and refuses to go on when a container of another Compose project, or of none, already has one of this cluster's container names, publishes this server's API port, or runs from this cluster's directory, naming each one. It also refuses an API port that is not a port number, hosts of one group that use different ports, and container names Docker would refuse or that are the same. Under `--check` the same read runs, so a dry run against hosts that run the first cluster shows whether the second fits. -The two nodes share the Docker host's memory, CPU and kernel limits. Each k3s server needs its own memory beside the pods scheduled on it, and both nodes' kubelets, containerd and pods draw on the same `fs.inotify.max_user_instances`, which a small VM may set low; measure both on the host before adding the second node. Each mesh device is the node's own container, so the two nodes are separate devices even when they are on the same tailnet and share a node name, and a name that is already a device there registers with a suffix (see `github_runner_cluster_node_name`). Each cluster's ARC role installs into the cluster its own kubeconfig reaches; a runner scale set's name can belong to only one controller at a time, so a scale set is moved by uninstalling it from one cluster before installing it in the other. +The two nodes share the Docker host's memory, CPU and kernel limits. Each k3s server needs its own memory beside the pods scheduled on it, and both nodes' kubelets, containerd and pods draw on the same `fs.inotify.max_user_instances`, which a small VM may set low; measure both on the host before adding the second node. Each mesh device is the node's own container, so the two nodes are separate devices even when they are on the same tailnet and share a node name, and a name that is already a device there registers with a suffix (see `github_runner_cluster_node_name`). Each cluster's ARC role installs into the cluster its own kubeconfig reaches, so an org can have scale sets in both clusters at once; a runner scale set's name can belong to only one controller at a time, so a scale set is moved by uninstalling it from one cluster before installing it in the other, and the ARC role's validation refuses an inventory that registers one scale set from both. Each Compose project runs its own `docker-prune` service against the same daemon. Two of them do the same work and are harmless. diff --git a/tests/unit/test_arc_filters.py b/tests/unit/test_arc_filters.py index 4557a8cf..cf1827c4 100644 --- a/tests/unit/test_arc_filters.py +++ b/tests/unit/test_arc_filters.py @@ -172,6 +172,56 @@ def test_settings_carries_the_profile_keys(self) -> None: self.assertEqual(arc.arc_profiles([org(profiles=[profile])])["profiles"][0]["settings"], profile) +class ClusterProfilesTest(unittest.TestCase): + # One org whose scale sets are split across two clusters: the default set on node-a, the builder set on node-b. + SPLIT = {"node-a": [org(profiles=[{"max_runners": 1}])], "node-b": [org(profiles=[{"suffix": "-builder", "max_runners": 1}])]} + + def test_an_org_may_be_installed_into_two_clusters(self) -> None: + result = arc.arc_cluster_profiles(self.SPLIT, ["node-a"]) + self.assertEqual(result["errors"], []) + self.assertEqual([profile["release"] for profile in result["profiles"]], ["example-runners"]) + + def test_only_the_clusters_own_hosts_are_expanded(self) -> None: + result = arc.arc_cluster_profiles(self.SPLIT, ["node-b"]) + self.assertEqual(result["errors"], []) + self.assertEqual([profile["release"] for profile in result["profiles"]], ["example-runners-builder"]) + + 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."]) + + def test_an_unknown_cluster_counts_every_host_as_one(self) -> None: + result = arc.arc_cluster_profiles(self.SPLIT) + self.assertEqual(len(result["errors"]), 1) + self.assertIn("for one cluster, on node-a (as 'Example') and on node-b", result["errors"][0]) + + def test_the_same_org_twice_on_one_host_is_still_an_error(self) -> None: + result = arc.arc_cluster_profiles({"node-a": [org("Example"), org("example")]}, ["node-a"]) + self.assertEqual(len(result["errors"]), 1) + self.assertIn("configured more than once in github_runner_arc_orgs (also as 'Example')", result["errors"][0]) + + 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."]) + + 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"]) + self.assertEqual(result["errors"], []) + + def test_another_clusters_invalid_orgs_are_left_to_its_own_check(self) -> None: + result = arc.arc_cluster_profiles({"node-a": [org()], "node-b": [org("Other", []), {"name": "Broken"}]}, ["node-a"]) + self.assertEqual(result["errors"], []) + + def test_the_clusters_checks_span_its_hosts(self) -> None: + result = arc.arc_cluster_profiles({"node-a": [org(profiles=[{"namespace": "shared", "max_runners": 1}])], "node-b": [org("Other", [{"namespace": "shared", "max_runners": 1}])]}, ["node-a", "node-b"]) + self.assertEqual(len(result["errors"]), 1) + self.assertIn("both map to namespace 'shared'", result["errors"][0]) + + def test_a_hosts_orgs_must_be_a_list(self) -> None: + result = arc.arc_cluster_profiles({"node-a": "Example"}, ["node-a"]) + self.assertEqual(result["errors"], ["node-a: github_runner_arc_orgs must be a list"]) + + 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. diff --git a/tests/unit/test_fleet_orgs.py b/tests/unit/test_fleet_orgs.py index 4593f574..e1537423 100644 --- a/tests/unit/test_fleet_orgs.py +++ b/tests/unit/test_fleet_orgs.py @@ -132,6 +132,25 @@ def test_the_same_org_on_another_host_is_still_a_duplicate(self) -> None: self.assertNotEqual(result.returncode, 0) self.assertIn("configured more than once", result.stdout) + # Two clusters in one inventory, localhost in the first and other-host in the second, as the playbooks resolve github_runner_cluster_group for the first cluster's play. + TWO_CLUSTERS = LOCALHOST_INVENTORY.replace("[local]", "[cluster_a]") + "[cluster_b]\nother-host ansible_connection=local\n" + + def test_an_org_split_across_two_clusters_is_accepted_and_each_cluster_sees_its_own_sets(self) -> None: + result, facts, _ = run(BASE | {"github_runner_cluster_group": "cluster_a", "github_runner_arc_orgs": orgs()}, self.TWO_CLUSTERS, {"other-host": {"github_runner_arc_orgs": [org | {"scale_set_profiles": [{"suffix": "-builder", "namespace": "builder-runners", "max_runners": 1}]} for org in orgs()]}}) + self.assertEqual(result.returncode, 0, result.stdout[-3000:]) + self.assertEqual(namespaces(facts), ["example-runners"]) + + def test_one_scale_set_on_two_clusters_is_refused(self) -> None: + result, _, _ = run(BASE | {"github_runner_cluster_group": "cluster_a", "github_runner_arc_orgs": orgs()}, self.TWO_CLUSTERS, {"other-host": {"github_runner_arc_orgs": orgs(namespace="other-runners")}}) + self.assertNotEqual(result.returncode, 0) + self.assertIn("example-org on localhost and example-org on other-host, which installs into another cluster, both register scale set 'example-org-runners'", result.stdout) + + def test_the_same_org_on_two_hosts_of_one_cluster_is_a_duplicate(self) -> None: + inventory = "[cluster_a]\nlocalhost ansible_connection=local\nother-host ansible_connection=local\n" + result, _, _ = run(BASE | {"github_runner_cluster_group": "cluster_a", "github_runner_arc_orgs": orgs()}, inventory, {"other-host": {"github_runner_arc_orgs": orgs(namespace="other-runners")}}) + self.assertNotEqual(result.returncode, 0) + self.assertIn("for one cluster, on other-host (as 'example-org') and on localhost", result.stdout) + # An autoscaled profile on the host that installs the platform, so the pool settings are checked. AUTOSCALED = BASE | { "github_runner_arc_heartbeat_gist_id": "0123abcd", From 5cfb8ea31abaa95b8d21926c85837797a5b7875e Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Wed, 21 Oct 2026 07:14:44 +0100 Subject: [PATCH 2/2] fix(arc): name every scale set after its release whatever a values file says The cross-cluster check compares scale sets by release name, which is the scale set's registration identity only while the chart's runnerScaleSetName defaults to it. A values file setting its own name broke that, so the name is now passed with --set alongside the other values the role owns. --- roles/github_runner_arc/tasks/install_scale_set_profile.yml | 2 +- roles/github_runner_arc/vars/main.yml | 4 ++-- tests/unit/test_pull_secret_source.py | 3 ++- 3 files changed, 5 insertions(+), 4 deletions(-) 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 380348e1..31ffa631 100644 --- a/roles/github_runner_arc/tasks/install_scale_set_profile.yml +++ b/roles/github_runner_arc/tasks/install_scale_set_profile.yml @@ -109,7 +109,7 @@ data: "{{ github_runner_arc_job_started_data }}" - name: Install/upgrade the scale-set release for {{ profile.label }} - # runnerScaleSetName is left to default to the release name: it is the scale set's registration identity on GitHub, so two releases sharing it make the second one's listener crash-loop registering a duplicate. scaleSetLabels is the pooling mechanism instead: an extra label every profile's runners carry alongside their own unique one, so runs-on: