From 8d6ad7447256bfce54852fa182b0093fdc6dd507 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sat, 26 Sep 2026 06:07:38 +0100 Subject: [PATCH] fix(arc): read the App's id from an existing App Secret The role required every org's app_id even with github_runner_arc_manage_secrets false, when it writes no App Secret and reads the id from the existing Secret wherever it needs one, so a consumer had to read that Secret itself just to pass the id back. app_id is now required only on a host that writes the App Secret; the fleet-wide expansion no longer asks for it, since only each host's own check knows whether it writes the Secret. With an existing App Secret the role now checks it holds github_app_id, github_app_installation_id and github_app_private_key, and that an app_id the org still sets matches the Secret's github_app_id, before anything changes. The Secret lookup is no_log, and the checks loop over a reduction to names, key names and the App's id, so no failure or verbose output can print a Secret's data. --- plugins/filter/arc.py | 12 +- roles/github_runner_arc/README.md | 6 +- roles/github_runner_arc/defaults/main.yml | 2 +- .../github_runner_arc/meta/argument_specs.yml | 2 +- .../tasks/check_existing_secret_contents.yml | 44 ++++++++ .../tasks/check_existing_secrets.yml | 14 +-- roles/github_runner_arc/tasks/validate.yml | 5 +- tests/unit/test_arc_filters.py | 8 +- tests/unit/test_existing_secrets.py | 104 ++++++++++++++++++ 9 files changed, 174 insertions(+), 23 deletions(-) create mode 100644 roles/github_runner_arc/tasks/check_existing_secret_contents.yml create mode 100644 tests/unit/test_existing_secrets.py diff --git a/plugins/filter/arc.py b/plugins/filter/arc.py index 4afe32b..74582fa 100644 --- a/plugins/filter/arc.py +++ b/plugins/filter/arc.py @@ -417,13 +417,14 @@ def _expand_profile(org_name: str, app_secret: str, index: int, profile: Any, er return expanded -def arc_profiles(orgs: Sequence[Any]) -> dict[str, Any]: +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) see the whole fleet. Args: - orgs: github_runner_arc_orgs entries, each with name, app_id, image and a non-empty scale_set_profiles list, 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. + 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. Returns: A dict with ``errors`` (messages, empty when valid), ``profiles`` (one dict per profile with org_name, suffix, namespace, release, app_secret, scale_set_labels, values_file, node_selector, autoscale, sizing, max_runners (the static maxRunners the role sets, or None), label and settings, the profile's own keys) and ``autoscaled`` (the one profile flagged autoscale, or None). @@ -442,9 +443,10 @@ def arc_profiles(orgs: Sequence[Any]) -> dict[str, Any]: if not org_name: errors.append(f"{where} has no name") continue - for key in ("app_id", "image"): - if not _text(org, key): - errors.append(f"{org_name}: {key} is required") + 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") + if not _text(org, "image"): + errors.append(f"{org_name}: 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)") diff --git a/roles/github_runner_arc/README.md b/roles/github_runner_arc/README.md index 7309eb8..182f6e4 100644 --- a/roles/github_runner_arc/README.md +++ b/roles/github_runner_arc/README.md @@ -12,7 +12,7 @@ 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`, `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 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: - `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. - `values_file`: Helm values for the release, a Jinja template read from `github_runner_arc_values_dir` on the control node (or an absolute path). Without it the role's `templates/runner-scale-set-values.yaml.j2` is used, driven by the profile's own `max_runners`, `min_runners`, `container_mode` (`dind` for the chart's Docker-in-Docker mode) and `resources` (the runner container's requests and limits). @@ -31,7 +31,7 @@ Before touching the cluster the role then checks the secrets it is about to writ - `github_runner_arc_listener_probes_enabled`: readiness and liveness probes on each listener's metrics endpoint, so a listener that starts and then fails its GitHub authentication is not counted ready during a rollout. Needs metrics on. - `github_runner_arc_node_label_key`, `github_runner_arc_node_label_value`: when the key is set, the controller, listeners and runner pods are restricted to nodes carrying the label. The role labels the nodes named in `github_runner_arc_labelled_nodes`. - `github_runner_arc_ghcr_username`, `github_runner_arc_ghcr_token`, `github_runner_arc_heartbeat_gh_token`: secrets, as plain variables. The pull credential is needed only for a static pull Secret. -- `github_runner_arc_manage_secrets`: `false` stops the role writing the App Secrets and `heartbeat-gh-token`, and instead checks before any change that they already exist. The role then needs no `private_key` or `installation_id`. +- `github_runner_arc_manage_secrets`: `false` stops the role writing the App Secrets and `heartbeat-gh-token`, and instead checks before any change that they already exist, and that each App Secret holds `github_app_id`, `github_app_installation_id` and `github_app_private_key`. The role then needs no `private_key` or `installation_id`, and no `app_id` either: it reads the App's id from the Secret where it needs it. An `app_id` that is set must match the Secret's `github_app_id`. - `github_runner_arc_manage_image_pull_secret`: `false` stops the role writing the pull Secret, so an existing one named `github_runner_arc_image_pull_secret_name` is used as it is, kept current by something else. No pull credential is needed and the pull check is skipped; the role checks the Secret exists. Follows `github_runner_arc_manage_secrets` unless set. - `github_runner_arc_image_pull_secret_source`: where a pull Secret the role writes gets its credential: `static` (the default) from `github_runner_arc_ghcr_username` and `github_runner_arc_ghcr_token`, or `app` from each org's GitHub App, renewed in the cluster (see [Image pull Secret from the GitHub App](#image-pull-secret-from-the-github-app)). `github_runner_arc_image_pull_secret_renewal_schedule` (default every 15 minutes) and `github_runner_arc_image_pull_secret_renewer_image` configure the renewal. - `github_runner_arc_image_pull_registry`, `github_runner_arc_image_pull_secret_name`, `github_runner_arc_verify_image_pull`: the pull Secret's registry and name, and whether to prove the credential before writing it. @@ -162,8 +162,8 @@ github_runner_arc_manage_secrets: false github_runner_arc_manage_image_pull_secret: false github_runner_arc_image_pull_secret_name: "existing-pull-secret" github_runner_arc_orgs: + # No app_id: the role reads it from the existing App Secret. - name: ExampleOrg - app_id: 123456 app_secret_name: "existing-app-secret" image: "ghcr.io/example/github-runner:2026.01.01" scale_set_profiles: diff --git a/roles/github_runner_arc/defaults/main.yml b/roles/github_runner_arc/defaults/main.yml index b011f3b..cb5c98d 100644 --- a/roles/github_runner_arc/defaults/main.yml +++ b/roles/github_runner_arc/defaults/main.yml @@ -1,7 +1,7 @@ --- # ARC (Actions Runner Controller) in an existing Kubernetes cluster: the controller, each org's runner scale sets, and the fleet-health platform (heartbeat and autoscaler). Each part is gated on this host's own github_runner_arc_orgs or github_runner_arc_heartbeat_gist_id, so any host that can reach the cluster's API can be the one Ansible installs from: a k3s server host brought up by github_runner_cluster, or localhost with a kubeconfig (playbooks/arc.yml). On a host github_runner_cluster made an agent, this role installs nothing. -# Orgs whose runner scale sets this host installs. Each entry: name, app_id, image, private_key (the App's PEM private key, from any source Ansible reads) or private_key_op_reference (an op:// reference the 1Password adapter resolves into private_key), optionally installation_id (resolved from the App when empty), optionally app_secret_name (the App Secret's name in each of the org's namespaces, default -github-app), and scale_set_profiles, a list of {suffix, namespace?, release_name?, values_file?, max_runners?, node_selector?, runs_on_label?, scale_set_labels?, autoscale?, sizing?}. sizing takes uniform node figures, or measured: true to read each eligible node's free capacity from the cluster at install time. Each profile becomes one namespace (namespace, default arc-runners-), two Secrets in it, and one Helm release (release_name, default -runners); setting both overrides lets the role take over scale sets an existing install created under its own names. See the README for every profile key. +# Orgs whose runner scale sets this host installs. Each entry: name, app_id (needed only when the role writes the App Secret; otherwise read from the existing Secret, and checked against it when set), image, private_key (the App's PEM private key, from any source Ansible reads) or private_key_op_reference (an op:// reference the 1Password adapter resolves into private_key), optionally installation_id (resolved from the App when empty), optionally app_secret_name (the App Secret's name in each of the org's namespaces, default -github-app), and scale_set_profiles, a list of {suffix, namespace?, release_name?, values_file?, max_runners?, node_selector?, runs_on_label?, scale_set_labels?, autoscale?, sizing?}. sizing takes uniform node figures, or measured: true to read each eligible node's free capacity from the cluster at install time. Each profile becomes one namespace (namespace, default arc-runners-), two Secrets in it, and one Helm release (release_name, default -runners); setting both overrides lets the role take over scale sets an existing install created under its own names. See the README for every profile key. github_runner_arc_orgs: [] # Directory on the control node that a relative scale_set_profiles[].values_file is read from. Values files are Jinja templates rendered on the control node, so the target host needs no checkout. A profile with no values_file uses the role's own templates/runner-scale-set-values.yaml.j2. diff --git a/roles/github_runner_arc/meta/argument_specs.yml b/roles/github_runner_arc/meta/argument_specs.yml index 9a9eb25..92fe49b 100644 --- a/roles/github_runner_arc/meta/argument_specs.yml +++ b/roles/github_runner_arc/meta/argument_specs.yml @@ -9,7 +9,7 @@ argument_specs: elements: dict default: [] description: - - Orgs whose runner scale sets this host installs. Each entry has name, app_id, image, private_key or private_key_op_reference, and optionally installation_id and app_secret_name (the App Secret's name, default -github-app). + - 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, autoscale and sizing. - 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. diff --git a/roles/github_runner_arc/tasks/check_existing_secret_contents.yml b/roles/github_runner_arc/tasks/check_existing_secret_contents.yml new file mode 100644 index 0000000..397db56 --- /dev/null +++ b/roles/github_runner_arc/tasks/check_existing_secret_contents.yml @@ -0,0 +1,44 @@ +--- +# Included from check_existing_secrets.yml with github_runner_arc_existing_secrets registered: fails on a referenced Secret that does not exist, or an App Secret the controller could not use. + +# Reduced to names, key names and the App's id, as JSON, which is all the checks below need, so that no task loops over the Secrets' data: a loop prints its items when it fails, and under -v when it skips. +- name: Reduce the Secrets to what the checks need + ansible.builtin.set_fact: + github_runner_arc_existing_secret_checks: >- + {%- set checks = namespace(entries=[]) -%} + {%- for result in github_runner_arc_existing_secrets.results -%} + {%- set data = (result.resources | first | default({})).data | default({}, true) -%} + {%- set checks.entries = checks.entries + [{'namespace': result.item.0, 'name': result.item.1, 'exists': result.resources | length > 0, + 'key_names': data.keys() | list, 'app_id': data.github_app_id | default('') | b64decode | trim}] -%} + {%- endfor -%} + {{ checks.entries | to_json }} + no_log: true + +- name: Fail when a referenced Secret does not exist + ansible.builtin.fail: + msg: >- + Secret {{ item.name }} does not exist in namespace {{ item.namespace }}. + The role is not managing it (github_runner_arc_manage_secrets or github_runner_arc_manage_image_pull_secret is false), so create it first: an App Secret holds github_app_id, github_app_installation_id and github_app_private_key; + a pull Secret has type kubernetes.io/dockerconfigjson; heartbeat-gh-token holds a gist-scoped GitHub token under the key token. + loop: "{{ github_runner_arc_existing_secret_checks | from_json }}" + loop_control: + label: "{{ item.namespace }}/{{ item.name }}" + when: not item.exists + +# The App Secrets this host does not write come first, one per profile. The role reads the App's id from them rather than requiring the org's app_id, so a Secret missing a key, or disagreeing with an app_id that is set, fails now rather than as a listener that cannot authenticate. +- 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 }} + {{ ('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.") }} + 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 }}" + 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_existing_secrets.yml b/roles/github_runner_arc/tasks/check_existing_secrets.yml index 395950e..69340ac 100644 --- a/roles/github_runner_arc/tasks/check_existing_secrets.yml +++ b/roles/github_runner_arc/tasks/check_existing_secrets.yml @@ -20,14 +20,8 @@ loop_control: label: "{{ item.0 }}/{{ item.1 }}" register: github_runner_arc_existing_secrets + # The results hold the Secrets' data. + no_log: true -- name: Fail when a referenced Secret does not exist - ansible.builtin.fail: - msg: >- - Secret {{ item.item.1 }} does not exist in namespace {{ item.item.0 }}. - The role is not managing it (github_runner_arc_manage_secrets or github_runner_arc_manage_image_pull_secret is false), so create it first: an App Secret holds github_app_id, github_app_installation_id and github_app_private_key; - a pull Secret has type kubernetes.io/dockerconfigjson; heartbeat-gh-token holds a gist-scoped GitHub token under the key token. - loop: "{{ github_runner_arc_existing_secrets.results }}" - loop_control: - label: "{{ item.item.0 }}/{{ item.item.1 }}" - when: item.resources | length == 0 +- name: Check the Secrets found + ansible.builtin.include_tasks: check_existing_secret_contents.yml diff --git a/roles/github_runner_arc/tasks/validate.yml b/roles/github_runner_arc/tasks/validate.yml index 1f4d6c3..d9115de 100644 --- a/roles/github_runner_arc/tasks/validate.yml +++ b/roles/github_runner_arc/tasks/validate.yml @@ -4,13 +4,14 @@ # 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 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. 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 + | exadev.github_runner.arc_profiles(require_app_id=false) }} - github_runner_arc_local: "{{ github_runner_arc_orgs | exadev.github_runner.arc_profiles }}" + github_runner_arc_local: "{{ github_runner_arc_orgs | exadev.github_runner.arc_profiles(require_app_id=github_runner_arc_manage_secrets | bool) }}" - name: Fail on invalid orgs or scale-set profiles ansible.builtin.fail: diff --git a/tests/unit/test_arc_filters.py b/tests/unit/test_arc_filters.py index 9ffe0ef..8f4a32d 100644 --- a/tests/unit/test_arc_filters.py +++ b/tests/unit/test_arc_filters.py @@ -106,11 +106,17 @@ def test_invalid_autoscale_value_is_an_error(self) -> None: def test_missing_required_org_fields_are_errors(self) -> None: result = arc.arc_profiles([{"name": "X", "scale_set_profiles": [{}]}, {"app_id": 1}, {"name": "Y", "app_id": 1, "image": "i", "scale_set_profiles": []}]) - self.assertIn("X: app_id is required", result["errors"]) + self.assertIn("X: app_id is required when the role writes the App Secret (github_runner_arc_manage_secrets); with an existing App Secret it is read from that Secret's github_app_id", result["errors"]) self.assertIn("X: image is required", result["errors"]) self.assertIn("github_runner_arc_orgs[1] has no name", result["errors"]) self.assertIn("Y: scale_set_profiles must be a non-empty list", result["errors"]) + def test_app_id_is_optional_when_the_role_does_not_write_the_app_secret(self) -> None: + without = {"name": "Example", "image": "ghcr.io/example/runner:1", "scale_set_profiles": [{"max_runners": 1}]} + self.assertEqual(arc.arc_profiles([without], require_app_id=False)["errors"], []) + self.assertEqual(arc.arc_profiles([without])["errors"], ["Example: app_id is required when the role writes the App Secret (github_runner_arc_manage_secrets); with an existing App Secret it is read from that Secret's github_app_id"]) + self.assertIn("Example: image is required", arc.arc_profiles([{**without, "image": ""}], require_app_id=False)["errors"]) + def test_a_private_key_and_a_1password_reference_together_are_an_error(self) -> None: both = dict(org(), private_key="pem", private_key_op_reference="op://v/i/f") self.assertIn("Example: set private_key or private_key_op_reference, not both (the 1Password adapter fills private_key from the reference)", arc.arc_profiles([both])["errors"]) diff --git a/tests/unit/test_existing_secrets.py b/tests/unit/test_existing_secrets.py new file mode 100644 index 0000000..0393ec9 --- /dev/null +++ b/tests/unit/test_existing_secrets.py @@ -0,0 +1,104 @@ +"""Tests for the github_runner_arc role's checks of the Secrets it does not write (tasks/check_existing_secret_contents.yml), driven with lookup results shaped as kubernetes.core.k8s_info returns them, so no cluster is needed. The checks must name what is wrong without ever printing a Secret's data, even at high verbosity.""" + +from __future__ import annotations + +import base64 +import json +import os +import shutil +import subprocess +import tempfile +import textwrap +import unittest +from pathlib import Path +from typing import Any + +REPO = Path(__file__).resolve().parents[2] +PRIVATE_KEY = "-----BEGIN RSA PRIVATE KEY-----\nexample-key-material-that-must-never-be-printed\n-----END RSA PRIVATE KEY-----\n" + +PLAYBOOK = textwrap.dedent( + """\ + - name: Check existing Secrets as the role does + hosts: localhost + connection: local + gather_facts: false + vars: {play_vars} + tasks: + - name: Expand the orgs as validation does + ansible.builtin.set_fact: + github_runner_arc_local: "{{{{ github_runner_arc_orgs | exadev.github_runner.arc_profiles(require_app_id=false) }}}}" + + - name: Check the looked-up Secrets + ansible.builtin.include_role: + name: exadev.github_runner.github_runner_arc + tasks_from: check_existing_secret_contents.yml + """ +) + + +def encode(value: str) -> str: + return base64.b64encode(value.encode()).decode() + + +def app_secret(data: dict[str, str] | None) -> dict[str, Any]: + """Return a lookup result for the App Secret with data, or for no Secret at all when data is None.""" + resources = [] if data is None else [{"kind": "Secret", "data": {key: encode(value) for key, value in data.items()}}] + return {"item": ["example-runners", "example-github-app"], "resources": resources} + + +FULL = {"github_app_id": "123", "github_app_installation_id": "456", "github_app_private_key": PRIVATE_KEY} + + +def run(result: dict[str, Any], app_id: Any = None, manage_secrets: bool = False) -> subprocess.CompletedProcess[str]: + """Run the checks against one App Secret lookup result, at -vvv, with the org's app_id set when app_id is not None.""" + 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]}} + with tempfile.TemporaryDirectory() as directory: + play = Path(directory) / "play.yml" + play.write_text(PLAYBOOK.format(play_vars=json.dumps(play_vars))) + env = {**os.environ, "ANSIBLE_COLLECTIONS_PATH": str(REPO / "playbooks" / "collections"), "ANSIBLE_LOCALHOST_WARNING": "false", "ANSIBLE_INVENTORY_UNPARSED_WARNING": "false", "ANSIBLE_HOME": directory, "ANSIBLE_LOCAL_TEMP": directory} + return subprocess.run(["ansible-playbook", "-vvv", str(play)], env=env, capture_output=True, text=True, stdin=subprocess.DEVNULL) + + +@unittest.skipIf(shutil.which("ansible-playbook") is None, "ansible-playbook is not installed") +class ExistingSecretsTest(unittest.TestCase): + def assert_nothing_leaked(self, result: subprocess.CompletedProcess[str]) -> None: + output = result.stdout + result.stderr + for secret in ("example-key-material-that-must-never-be-printed", encode(PRIVATE_KEY)): + self.assertNotIn(secret, output) + + def test_a_complete_app_secret_passes_without_an_app_id(self) -> None: + result = run(app_secret(FULL)) + self.assertEqual(result.returncode, 0, result.stdout[-3000:]) + self.assert_nothing_leaked(result) + + def test_an_app_id_matching_the_secret_passes(self) -> None: + result = run(app_secret(FULL), app_id=123) + self.assertEqual(result.returncode, 0, result.stdout[-3000:]) + + 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.assert_nothing_leaked(result) + + def test_a_secret_missing_keys_fails_naming_them(self) -> None: + result = run(app_secret({"github_app_id": "123", "github_app_private_key": PRIVATE_KEY})) + self.assertNotEqual(result.returncode, 0) + self.assertIn("has no github_app_installation_id; it needs github_app_id, github_app_installation_id and github_app_private_key.", result.stdout) + self.assert_nothing_leaked(result) + + def test_a_missing_secret_fails(self) -> None: + result = run(app_secret(None)) + self.assertNotEqual(result.returncode, 0) + self.assertIn("Secret example-github-app does not exist in namespace example-runners.", result.stdout) + + 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:]) + + +if __name__ == "__main__": + unittest.main()