diff --git a/plugins/filter/arc.py b/plugins/filter/arc.py index d8c77f4..50a346c 100644 --- a/plugins/filter/arc.py +++ b/plugins/filter/arc.py @@ -118,7 +118,43 @@ def _check_dns_label(kind: str, name: str, where: str, errors: list[str]) -> Non errors.append(f"{where}: the derived {kind} '{name}' is not a valid Kubernetes name (lower-case letters, digits and '-', at most {_DNS_LABEL_MAX_LENGTH} characters)") -def _expand_profile(org: Mapping[str, Any], org_name: str, index: int, profile: Any, errors: list[str]) -> dict[str, Any] | None: +def _optional_name(mapping: Mapping[str, Any], key: str, where: str, errors: list[str]) -> str: + """Return an optional name override, or empty when it is unset, recording an error when it is set but not a non-empty string.""" + value = mapping.get(key) + if value is None: + return "" + if not isinstance(value, str) or not value: + errors.append(f"{where}.{key} must be a non-empty string when set") + return "" + return value + + +def _scale_set_labels(profile: Mapping[str, Any], default: str, where: str, errors: list[str]) -> list[str]: + """Return the scaleSetLabels a profile's release sets: scale_set_labels when given (an empty list sets none), otherwise runs_on_label or the org's pooled default.""" + if profile.get("scale_set_labels") is None: + return [_text(profile, "runs_on_label") or default] + if profile.get("runs_on_label") is not None: + errors.append(f"{where}: set runs_on_label or scale_set_labels, not both") + labels = profile["scale_set_labels"] + if not isinstance(labels, Sequence) or isinstance(labels, (str, bytes)) or not all(isinstance(label, str) and label for label in labels): + errors.append(f"{where}.scale_set_labels must be a list of non-empty strings (empty sets no scaleSetLabels)") + return [] + return list(labels) + + +def _explicit_max_runners(profile: Mapping[str, Any], where: str, errors: list[str]) -> int | None: + """Return the profile's own max_runners as a whole number, None when unset, recording an error when it is not a whole number of at least 0.""" + value = profile.get("max_runners") + if value is None: + return None + number = _decimal(value) + if number is None or number < 0 or number != number.to_integral_value(): + errors.append(f"{where}.max_runners must be a whole number of at least 0 (got {value!r})") + return None + return int(number) + + +def _expand_profile(org_name: str, app_secret: str, index: int, profile: Any, errors: list[str]) -> dict[str, Any] | None: """Describe one scale-set profile of an org, or record why it cannot be described.""" where = f"{org_name} scale_set_profiles[{index}]" if not isinstance(profile, Mapping): @@ -139,17 +175,26 @@ def _expand_profile(org: Mapping[str, Any], org_name: str, index: int, profile: if profile.get("sizing") is not None: sizing_result = arc_max_runners(profile["sizing"]) errors.extend(f"{where}: {message}" for message in sizing_result["errors"]) + explicit_max_runners = _explicit_max_runners(profile, where, errors) + # The static maxRunners the role sets on the release: the profile's own max_runners wins; otherwise sizing supplies it, except on the autoscaled profile, whose sizing sets the autoscaler's ceiling rather than its floor. + if explicit_max_runners is not None: + max_runners = explicit_max_runners + elif sizing_result is not None and not sizing_result["errors"] and not autoscale: + max_runners = sizing_result["max_runners"] + else: + max_runners = None expanded = { "org_name": org_name, "suffix": suffix, - "namespace": f"arc-runners-{org_lower}{suffix}", - "release": f"{org_lower}-runners{suffix}", - "app_secret": f"{org_lower}-github-app", - "runs_on_label": _text(profile, "runs_on_label") or f"{org_lower}-runners", + "namespace": _optional_name(profile, "namespace", where, errors) or f"arc-runners-{org_lower}{suffix}", + "release": _optional_name(profile, "release_name", where, errors) or f"{org_lower}-runners{suffix}", + "app_secret": app_secret, + "scale_set_labels": _scale_set_labels(profile, f"{org_lower}-runners", where, errors), "values_file": _text(profile, "values_file"), "node_selector": dict(node_selector), "autoscale": autoscale, "sizing": sizing_result, + "max_runners": max_runners, "label": f"{org_name}{suffix}", "settings": dict(profile), } @@ -167,10 +212,10 @@ def arc_profiles(orgs: Sequence[Any]) -> dict[str, Any]: Pass every org configured anywhere in the inventory, not one host's, so that the cross-host checks (an org configured twice, two profiles sharing a namespace, more than one autoscaled profile) see the whole fleet. Args: - orgs: github_runner_arc_orgs entries, each with name, app_id, image and a non-empty scale_set_profiles list, and at most one of private_key and private_key_op_reference. + orgs: github_runner_arc_orgs entries, each with name, app_id, image and a non-empty scale_set_profiles list, at most one of private_key and private_key_op_reference, and optionally app_secret_name. A profile may override its namespace and release_name, and set scale_set_labels in place of runs_on_label. Returns: - A dict with ``errors`` (messages, empty when valid), ``profiles`` (one dict per profile with org_name, suffix, namespace, release, app_secret, runs_on_label, values_file, node_selector, autoscale, sizing, label and settings, the profile's own keys) and ``autoscaled`` (the one profile flagged autoscale, or None). + A dict with ``errors`` (messages, empty when valid), ``profiles`` (one dict per profile with org_name, suffix, namespace, release, app_secret, scale_set_labels, values_file, node_selector, autoscale, sizing, max_runners (the static maxRunners the role sets, or None), label and settings, the profile's own keys) and ``autoscaled`` (the one profile flagged autoscale, or None). """ errors: list[str] = [] profiles: list[dict[str, Any]] = [] @@ -200,15 +245,22 @@ def arc_profiles(orgs: Sequence[Any]) -> dict[str, Any]: if not isinstance(org_profiles, Sequence) or isinstance(org_profiles, (str, bytes)) or not org_profiles: errors.append(f"{org_name}: scale_set_profiles must be a non-empty list") continue + app_secret = _optional_name(org, "app_secret_name", org_name, errors) or f"{org_name.lower()}-github-app" for profile_index, profile in enumerate(org_profiles): - expanded = _expand_profile(org, org_name, profile_index, profile, errors) + expanded = _expand_profile(org_name, app_secret, profile_index, profile, errors) if expanded is not None: profiles.append(expanded) namespaces: dict[str, str] = {} + releases: dict[tuple[str, str], str] = {} for profile in profiles: if profile["namespace"] in namespaces: - errors.append(f"{profile['label']} and {namespaces[profile['namespace']]} both map to namespace '{profile['namespace']}'; give one of them a different suffix") + errors.append(f"{profile['label']} and {namespaces[profile['namespace']]} both map to namespace '{profile['namespace']}'; give one of them a different suffix or namespace") namespaces.setdefault(profile["namespace"], profile["label"]) + # The release name is also the scale set's registration name on GitHub, so two of an org's releases sharing it would register a duplicate even from different namespaces. + release_key = (profile["org_name"].lower(), profile["release"]) + if release_key in releases: + errors.append(f"{profile['label']} and {releases[release_key]} both use release name '{profile['release']}', which is the scale set's name on GitHub; give one of them a different suffix or release_name") + releases.setdefault(release_key, profile["label"]) autoscaled = [profile for profile in profiles if profile["autoscale"]] if len(autoscaled) > 1: errors.append(f"At most one scale-set profile may set autoscale: true, because the autoscaler budgets one pool's memory for one scale set; found {', '.join(profile['label'] for profile in autoscaled)}") diff --git a/roles/github_runner_arc/README.md b/roles/github_runner_arc/README.md index a170fcc..060e41d 100644 --- a/roles/github_runner_arc/README.md +++ b/roles/github_runner_arc/README.md @@ -6,28 +6,33 @@ It runs in two ways. Inside `playbooks/site.yml`, against a k3s server host that ## Validation -`tasks/validate.yml` checks every input that needs neither secrets nor a cluster: each org's fields, profile names that would collide or make invalid Kubernetes names, the same org configured on two hosts, more than one autoscaled profile, values files that do not exist, the autoscaler's pool settings, sizing inputs, and the controller, probe and node label settings. Both playbooks run it in a play of its own before anything changes; the role runs it itself when included some other way. It also bootstraps the heartbeat gist when `github_runner_arc_heartbeat_bootstrap_gist` is on and no host in the inventory has one: it creates the gist with the control node's `gh` session and stops, printing the id to record. +`tasks/validate.yml` checks every input that needs neither secrets nor a cluster: each org's fields, profile names (derived or overridden) that would collide or make invalid Kubernetes names, two of an org's profiles sharing a release name, a `max_runners` that is not a whole number, the controller's release name and namespace, the same org configured on two hosts, more than one autoscaled profile, values files that do not exist, the autoscaler's pool settings, sizing inputs, and the controller, probe and node label settings. Both playbooks run it in a play of its own before anything changes; the role runs it itself when included some other way. It also bootstraps the heartbeat gist when `github_runner_arc_heartbeat_bootstrap_gist` is on and no host in the inventory has one: it creates the gist with the control node's `gh` session and stops, printing the id to record. Before touching the cluster the role then checks the secrets it is about to write (each org's App key looks like a PEM key, the pull credential and heartbeat token are set) and proves the pull credential can read every image it installs from `github_runner_arc_image_pull_registry`, by getting a pull-scoped registry token and fetching each image's manifest. ## Variables -- `github_runner_arc_orgs`: the orgs this host installs. Each entry has `name`, `app_id`, `image`, `private_key` (the App's PEM private key, from any source Ansible reads) or `private_key_op_reference` (an `op://` reference that the `github_runner_secrets_onepassword` adapter resolves into `private_key`), optionally `installation_id` (resolved from the App when empty), and `scale_set_profiles`, a list of profiles: +- `github_runner_arc_orgs`: the orgs this host installs. Each entry has `name`, `app_id`, `image`, `private_key` (the App's PEM private key, from any source Ansible reads) or `private_key_op_reference` (an `op://` reference that the `github_runner_secrets_onepassword` adapter resolves into `private_key`), optionally `installation_id` (resolved from the App when empty), optionally `app_secret_name` (the App Secret's name in each of the org's namespaces, default `-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). - `node_selector`: a nodeSelector for the runner pods, merged over the eligibility label below. + - `max_runners`: the release's `maxRunners`. It takes precedence over the values file and over `sizing`; on the autoscaled profile it is the floor the autoscaler starts from. - `runs_on_label`: the extra `scaleSetLabels` entry the runners carry. Default `-runners`, which pools every profile of the org under one `runs-on` label; set a different one to keep a profile out of that pool. + - `scale_set_labels`: the whole `scaleSetLabels` list, in place of `runs_on_label`. An empty list sets no `scaleSetLabels`, so jobs target the scale set by its release name. - `autoscale`: `true` on at most one profile in the whole inventory makes it the scale set the autoscaler manages. - - `sizing`: derive a runner ceiling from node capacity (see Sizing). On an ordinary profile it sets `maxRunners`; on the autoscaled profile it sets the autoscaler's ceiling, leaving `maxRunners` as the floor. + - `sizing`: derive a runner ceiling from node capacity (see Sizing). On an ordinary profile without `max_runners` it sets `maxRunners`; on the autoscaled profile it sets the autoscaler's ceiling, leaving `maxRunners` as the floor. - `github_runner_arc_values_dir`: the control-node directory relative `values_file` paths are read from. - `github_runner_arc_kubeconfig_path`: the kubeconfig on the host the role runs against. Defaults to `github_runner_cluster`'s kubeconfig; empty means `KUBECONFIG` or `~/.kube/config`. - `github_runner_arc_controller_chart_version`, `github_runner_arc_scaleset_chart_version`: chart pins; unset installs the latest chart. +- `github_runner_arc_controller_release_name`, `github_runner_arc_controller_namespace`: the controller's Helm release and namespace, default `arc` in `actions-runner-controller`. The stale-listener check, the heartbeat's RBAC and the heartbeat's controller check use them too. - `github_runner_arc_controller_replicas`: above 1, the chart turns on leader election and the role adds a preferred pod anti-affinity across nodes. `github_runner_arc_controller_extra_values` is merged over the role's controller values. - `github_runner_arc_metrics_enabled` (and the `_addr`/`_endpoint` settings): Prometheus metrics for the controller and every listener; the 0.14 chart only enables them together. - `github_runner_arc_listener_probes_enabled`: readiness and liveness probes on each listener's metrics endpoint, so a listener that starts and then fails its GitHub authentication is not counted ready during a rollout. Needs metrics on. - `github_runner_arc_node_label_key`, `github_runner_arc_node_label_value`: when the key is set, the controller, listeners and runner pods are restricted to nodes carrying the label. The role labels the nodes named in `github_runner_arc_labelled_nodes`. - `github_runner_arc_ghcr_username`, `github_runner_arc_ghcr_token`, `github_runner_arc_heartbeat_gh_token`: secrets, as plain variables. -- `github_runner_arc_manage_secrets`: `false` stops the role writing the App and pull Secrets, and instead checks before any change that each namespace already holds them. +- `github_runner_arc_manage_secrets`: `false` stops the role writing the App Secrets and `heartbeat-gh-token`, and instead checks before any change that they already exist. The role then needs no `private_key` or `installation_id`. +- `github_runner_arc_manage_image_pull_secret`: `false` stops the role writing the pull Secret, so an existing one named `github_runner_arc_image_pull_secret_name` is used as it is, for example one that something else mints from a GitHub App installation token. No pull credential is needed and the pull check is skipped; the role checks the Secret exists. Follows `github_runner_arc_manage_secrets` unless set. - `github_runner_arc_image_pull_registry`, `github_runner_arc_image_pull_secret_name`, `github_runner_arc_verify_image_pull`: the pull Secret's registry and name, and whether to prove the credential before writing it. - `github_runner_arc_heartbeat_gist_id`: install the fleet-health platform from this host, refreshing this gist. `github_runner_arc_heartbeat_bootstrap_gist`, `github_runner_arc_heartbeat_gist_description` and `github_runner_arc_heartbeat_gist_consumer` control the bootstrap described above. - `github_runner_arc_autoscaler_usable_budget_gi`, `github_runner_arc_autoscaler_max_ceiling`, `github_runner_arc_autoscaler_floor`: required when a profile sets `autoscale: true` and the platform is installed. The floor must equal the autoscaled profile's `maxRunners`, which every Helm upgrade reverts to. The other `github_runner_arc_autoscaler_*` and `github_runner_arc_heartbeat_*` settings have defaults; see `defaults/main.yml`. @@ -84,3 +89,28 @@ github_runner_arc_orgs: values_file: "example-docker-values.yaml" runs_on_label: "example-docker" ``` + +## Managing an existing install + +A cluster whose controller and scale set were installed under other names can be managed by setting those names, rather than getting a second controller and scale set beside them. The role then upgrades the existing releases in place. + +```yaml +github_runner_arc_controller_release_name: "existing-controller" +github_runner_arc_controller_namespace: "existing-controller-ns" +# The App Secret and the pull Secret already exist and are kept current elsewhere. +github_runner_arc_manage_secrets: false +github_runner_arc_manage_image_pull_secret: false +github_runner_arc_image_pull_secret_name: "existing-pull-secret" +github_runner_arc_orgs: + - name: ExampleOrg + app_id: 123456 + app_secret_name: "existing-app-secret" + image: "ghcr.io/example/github-runner:2026.01.01" + scale_set_profiles: + - namespace: "existing-runners-ns" + release_name: "existing-runners" + values_file: "existing-runners-values.yaml" + max_runners: 6 + # Jobs target the release name, so no scaleSetLabels. + scale_set_labels: [] +``` diff --git a/roles/github_runner_arc/defaults/main.yml b/roles/github_runner_arc/defaults/main.yml index 24b35bb..7f75e9f 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), and scale_set_profiles, a list of {suffix, values_file?, node_selector?, runs_on_label?, autoscale?, sizing?}. Each profile becomes one namespace (arc-runners-), two Secrets in it, and one Helm release (-runners). See the README for every profile key. +# 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?}. 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. @@ -12,6 +12,10 @@ github_runner_arc_kubeconfig_path: "{{ github_runner_cluster_kubeconfig_path | d # Helm chart version pins, left undefined so the tasks omit --version and Helm installs the latest chart. Set github_runner_arc_controller_chart_version and/or github_runner_arc_scaleset_chart_version (e.g. "0.14.2") to hold a chart back. +# The ARC controller's Helm release name and namespace. The stale-listener check, the heartbeat's RBAC and the heartbeat itself look for the controller here too, so a cluster whose controller was installed under other names sets both to those names rather than getting a second controller. +github_runner_arc_controller_release_name: "arc" +github_runner_arc_controller_namespace: "actions-runner-controller" + # ARC controller. Replicas above 1 turn on the chart's leader election and spread the pods across nodes with a preferred pod anti-affinity. Extra values are merged over the role's own controller values. github_runner_arc_controller_replicas: 1 github_runner_arc_controller_extra_values: {} @@ -33,10 +37,13 @@ github_runner_arc_labelled_nodes: [] # Secrets, as plain variables from any source Ansible reads: github_runner_arc_ghcr_username and github_runner_arc_ghcr_token (a registry pull credential for the runner and platform images) and github_runner_arc_heartbeat_gh_token (a PAT with gist scope), plus each org's private_key above. No defaults. -# false means the role does not write the App or pull Secrets itself, and instead checks before changing anything that each profile's namespace already holds -github-app and the pull secret (and that the platform namespace holds the pull secret and heartbeat-gh-token). +# false means the role does not write the App Secrets or heartbeat-gh-token itself (and needs no private_key or installation_id), and instead checks before changing anything that each profile's namespace already holds its App Secret (and that the platform namespace holds heartbeat-gh-token). github_runner_arc_manage_secrets: true -# Registry the pull Secret authenticates to, and its name in each namespace. +# Whether the role writes the image pull Secret named below into each namespace from github_runner_arc_ghcr_username and github_runner_arc_ghcr_token. false uses a pull Secret something else creates and keeps current, such as one minted from a GitHub App installation token: the role then needs no pull credential, skips the image pull check, and only checks the Secret exists before changing anything. Follows github_runner_arc_manage_secrets unless set. +github_runner_arc_manage_image_pull_secret: "{{ github_runner_arc_manage_secrets }}" + +# Registry the pull Secret authenticates to, and its name in each namespace, whether the role writes it or uses an existing one. github_runner_arc_image_pull_registry: "ghcr.io" github_runner_arc_image_pull_secret_name: "ghcr-pull" diff --git a/roles/github_runner_arc/meta/argument_specs.yml b/roles/github_runner_arc/meta/argument_specs.yml new file mode 100644 index 0000000..5c9dd85 --- /dev/null +++ b/roles/github_runner_arc/meta/argument_specs.yml @@ -0,0 +1,206 @@ +--- +# The role's variables, checked when the role runs. defaults/main.yml holds each default and the reasoning behind it; plugins/filter/arc.py checks the structure of each org and scale-set profile, so the org entries are described here but not restricted key by key. +argument_specs: + main: + short_description: Installs Actions Runner Controller, runner scale sets and the fleet-health platform into a Kubernetes cluster. + options: + github_runner_arc_orgs: + type: list + 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). + - 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. + github_runner_arc_values_dir: + type: str + default: "" + description: Directory on the control node that a relative values_file is read from. + github_runner_arc_kubeconfig_path: + type: str + description: Kubeconfig on the host the role runs against. Empty means KUBECONFIG, then ~/.kube/config. + github_runner_arc_controller_chart_version: + type: str + description: Pins the controller chart. Unset installs the latest chart. + github_runner_arc_scaleset_chart_version: + type: str + description: Pins the scale-set chart. Unset installs the latest chart. + github_runner_arc_controller_release_name: + type: str + default: arc + description: Helm release name of the ARC controller. + github_runner_arc_controller_namespace: + type: str + default: actions-runner-controller + description: Namespace of the ARC controller, where its listeners run and where the heartbeat checks its Deployment. + github_runner_arc_controller_replicas: + type: int + default: 1 + description: Controller replicas. Above 1 turns on leader election and a preferred anti-affinity across nodes. + github_runner_arc_controller_extra_values: + type: dict + default: {} + description: Values merged over the role's own controller values. + github_runner_arc_metrics_enabled: + type: bool + default: false + description: Prometheus metrics for the controller and every listener. + github_runner_arc_controller_metrics_addr: + type: str + default: ":8080" + description: The controller's metrics listen address. + github_runner_arc_listener_metrics_addr: + type: str + default: ":8080" + description: Each listener's metrics listen address. + github_runner_arc_listener_metrics_endpoint: + type: str + default: /metrics + description: Each listener's metrics path. + github_runner_arc_listener_probes_enabled: + type: bool + default: false + description: Readiness and liveness probes on each listener's metrics endpoint. Needs metrics on. + github_runner_arc_listener_probe_port: + type: int + default: 8080 + description: Port the listener probes poll. + github_runner_arc_node_label_key: + type: str + default: "" + description: When set, the controller, listeners and runner pods are restricted to nodes with this label. + github_runner_arc_node_label_value: + type: raw + default: "true" + description: Value of the eligibility label. + github_runner_arc_labelled_nodes: + type: list + elements: str + default: [] + description: Nodes the role gives the eligibility label. + github_runner_arc_ghcr_username: + type: str + description: Registry user for the pull Secret. Required when the role writes the pull Secret. + github_runner_arc_ghcr_token: + type: str + no_log: true + description: Registry token for the pull Secret. Required when the role writes the pull Secret. + github_runner_arc_heartbeat_gh_token: + type: str + no_log: true + description: GitHub token with gist scope for the heartbeat. Required when the role writes heartbeat-gh-token. + github_runner_arc_manage_secrets: + type: bool + default: true + description: Whether the role writes the App Secrets and heartbeat-gh-token. false checks they already exist instead. + github_runner_arc_manage_image_pull_secret: + type: bool + description: + - Whether the role writes the pull Secret. false uses an existing Secret named github_runner_arc_image_pull_secret_name, which something else keeps current, and checks it exists. + - Follows github_runner_arc_manage_secrets unless set. + github_runner_arc_image_pull_registry: + type: str + default: ghcr.io + description: Registry the pull Secret authenticates to. + github_runner_arc_image_pull_secret_name: + type: str + default: ghcr-pull + description: Name of the pull Secret in each namespace, whether the role writes it or uses an existing one. + github_runner_arc_verify_image_pull: + type: bool + default: true + description: Prove the pull credential can read each image before writing the pull Secret. + github_runner_arc_heartbeat_gist_id: + type: str + default: "" + description: Installs the fleet-health platform from this host, refreshing this gist. + github_runner_arc_heartbeat_window_seconds: + type: int + default: 600 + description: How far ahead the heartbeat sets its timestamp. + github_runner_arc_heartbeat_interval_seconds: + type: int + default: 180 + description: How often the heartbeat checks the cluster. + github_runner_arc_heartbeat_bootstrap_gist: + type: bool + default: true + description: Create a heartbeat gist and stop when no host in the inventory has one. + github_runner_arc_heartbeat_gist_description: + type: str + default: GitHub Actions runner fleet health heartbeat + description: Description of a bootstrapped gist. + github_runner_arc_heartbeat_gist_consumer: + type: str + default: "" + description: Anything else that must be told a new gist's id, named in the bootstrap failure message. + github_runner_arc_platform_namespace: + type: str + default: github-runner-platform + description: Namespace of the heartbeat and autoscaler. + github_runner_arc_heartbeat_image: + type: str + description: Heartbeat image. + github_runner_arc_autoscaler_image: + type: str + description: Autoscaler image. + github_runner_arc_autoscaler_dry_run: + type: bool + default: true + description: Report the autoscaler's decisions without applying them. + github_runner_arc_autoscaler_poll_seconds: + type: int + default: 45 + description: Autoscaler poll interval. + github_runner_arc_autoscaler_raise_confirm_polls: + type: int + default: 2 + description: Consecutive polls that must agree before the autoscaler raises maxRunners. + github_runner_arc_autoscaler_mem_available_pressure_pct: + type: int + default: 15 + description: Available memory percentage below which the autoscaler treats a node as under pressure. + github_runner_arc_autoscaler_usable_budget_gi: + type: int + description: Memory runner pods may use across every node. Required for an autoscaled profile. + github_runner_arc_autoscaler_max_ceiling: + type: int + description: The autoscaler's ceiling, unless the autoscaled profile sets sizing. + github_runner_arc_autoscaler_floor: + type: int + description: The autoscaler's floor, equal to the autoscaled profile's maxRunners. + github_runner_arc_app_setup_org: + type: str + default: "" + description: Organisation the App setup creates the App on. + github_runner_arc_app_setup_name: + type: str + default: "" + description: The App's display name. + github_runner_arc_app_setup_description: + type: str + description: The App's description. + 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. + github_runner_arc_app_setup_permissions: + type: dict + description: The App's permissions. + github_runner_arc_app_manifest_code: + type: str + default: "" + description: The one-time code from the App setup's first phase. + github_runner_arc_app_private_key_path: + type: str + default: "" + description: New file the App setup writes the private key to. + github_runner_arc_app_setup_install_retries: + type: int + default: 40 + description: How many times the App setup checks for the installation. + github_runner_arc_app_setup_install_delay: + type: int + default: 15 + description: Seconds between those checks. diff --git a/roles/github_runner_arc/tasks/check_existing_secrets.yml b/roles/github_runner_arc/tasks/check_existing_secrets.yml index b46a727..395950e 100644 --- a/roles/github_runner_arc/tasks/check_existing_secrets.yml +++ b/roles/github_runner_arc/tasks/check_existing_secrets.yml @@ -1,5 +1,5 @@ --- -# With github_runner_arc_manage_secrets false the role writes no Secrets, so each one the releases and Deployments reference must already exist. Checked before anything is installed: a missing App Secret otherwise shows up as a listener that never registers, and a missing pull Secret as pods in ImagePullBackOff with a registry "denied" that reads like a wrong image tag. +# With github_runner_arc_manage_secrets false the role writes no App or heartbeat Secrets, and with github_runner_arc_manage_image_pull_secret false no pull Secret, so each one the releases and Deployments reference that the role does not write must already exist. Checked before anything is installed: a missing App Secret otherwise shows up as a listener that never registers, and a missing pull Secret as pods in ImagePullBackOff with a registry "denied" that reads like a wrong image tag. - name: Look up each Secret this host's releases and Deployments reference kubernetes.core.k8s_info: @@ -8,10 +8,14 @@ name: "{{ item.1 }}" loop: >- {{ - github_runner_arc_local.profiles | map(attribute='namespace') | zip(github_runner_arc_local.profiles | map(attribute='app_secret')) | list - + github_runner_arc_local.profiles | map(attribute='namespace') | product([github_runner_arc_image_pull_secret_name]) | list - + ([github_runner_arc_platform_namespace] | product([github_runner_arc_image_pull_secret_name, 'heartbeat-gh-token']) | list - if github_runner_arc_heartbeat_gist_id | length > 0 else []) + (github_runner_arc_local.profiles | map(attribute='namespace') | zip(github_runner_arc_local.profiles | map(attribute='app_secret')) | list + if not (github_runner_arc_manage_secrets | bool) else []) + + (github_runner_arc_local.profiles | map(attribute='namespace') | product([github_runner_arc_image_pull_secret_name]) | list + if not (github_runner_arc_manage_image_pull_secret | bool) else []) + + ([github_runner_arc_platform_namespace] | product([github_runner_arc_image_pull_secret_name]) | list + if github_runner_arc_heartbeat_gist_id | length > 0 and not (github_runner_arc_manage_image_pull_secret | bool) else []) + + ([github_runner_arc_platform_namespace] | product(['heartbeat-gh-token']) | list + if github_runner_arc_heartbeat_gist_id | length > 0 and not (github_runner_arc_manage_secrets | bool) else []) }} loop_control: label: "{{ item.0 }}/{{ item.1 }}" @@ -21,7 +25,7 @@ ansible.builtin.fail: msg: >- Secret {{ item.item.1 }} does not exist in namespace {{ item.item.0 }}. - github_runner_arc_manage_secrets is false, so create it first: an App Secret holds github_app_id, github_app_installation_id and github_app_private_key; + 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: diff --git a/roles/github_runner_arc/tasks/check_secrets.yml b/roles/github_runner_arc/tasks/check_secrets.yml index 27edec9..e653c2a 100644 --- a/roles/github_runner_arc/tasks/check_secrets.yml +++ b/roles/github_runner_arc/tasks/check_secrets.yml @@ -12,16 +12,21 @@ loop_control: loop_var: org label: "{{ org.name }}" - when: org.private_key | default('') is not search('-----BEGIN [A-Z ]*PRIVATE KEY-----') + when: + - github_runner_arc_manage_secrets | bool + - org.private_key | default('') is not search('-----BEGIN [A-Z ]*PRIVATE KEY-----') - name: Fail when the image pull credential is missing ansible.builtin.fail: msg: github_runner_arc_ghcr_username and github_runner_arc_ghcr_token must both be set to write the {{ github_runner_arc_image_pull_secret_name }} pull Secret. - when: (github_runner_arc_ghcr_username | default('')) | length == 0 or (github_runner_arc_ghcr_token | default('')) | length == 0 + when: + - github_runner_arc_manage_image_pull_secret | bool + - (github_runner_arc_ghcr_username | default('')) | length == 0 or (github_runner_arc_ghcr_token | default('')) | length == 0 - name: Fail when the heartbeat's GitHub token is missing ansible.builtin.fail: msg: github_runner_arc_heartbeat_gh_token (a GitHub token with gist scope) must be set to install the fleet-health platform. when: + - github_runner_arc_manage_secrets | bool - github_runner_arc_heartbeat_gist_id | length > 0 - (github_runner_arc_heartbeat_gh_token | default('')) | length == 0 diff --git a/roles/github_runner_arc/tasks/install_controller.yml b/roles/github_runner_arc/tasks/install_controller.yml index 62cd36d..65da77c 100644 --- a/roles/github_runner_arc/tasks/install_controller.yml +++ b/roles/github_runner_arc/tasks/install_controller.yml @@ -3,9 +3,9 @@ - name: Install/upgrade the ARC controller release kubernetes.core.helm: - name: arc + name: "{{ github_runner_arc_controller_release_name }}" chart_ref: oci://ghcr.io/actions/actions-runner-controller-charts/gha-runner-scale-set-controller chart_version: "{{ github_runner_arc_controller_chart_version | default(omit) }}" - release_namespace: actions-runner-controller + release_namespace: "{{ github_runner_arc_controller_namespace }}" create_namespace: true values: "{{ lookup('ansible.builtin.template', 'controller-values.yaml.j2') | from_yaml | default({}, true) | combine(github_runner_arc_controller_extra_values, recursive=true) }}" diff --git a/roles/github_runner_arc/tasks/install_org.yml b/roles/github_runner_arc/tasks/install_org.yml index f2c8d24..bc45253 100644 --- a/roles/github_runner_arc/tasks/install_org.yml +++ b/roles/github_runner_arc/tasks/install_org.yml @@ -1,14 +1,16 @@ --- # 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. +# 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 }} ansible.builtin.include_tasks: resolve_installation_id.yml - when: (org.installation_id | default('')) | length == 0 + when: github_runner_arc_manage_secrets | bool and (org.installation_id | default('')) | length == 0 - name: Set the effective installation ID of {{ org.name }} # Read directly by name: resolve_installation_id.yml sets this with a plain set_fact and no delegate_facts, so it attaches to this host, not to the localhost it delegated to. ansible.builtin.set_fact: github_runner_arc_installation_id: "{{ github_runner_arc_resolved_installation_id | default(org.installation_id) }}" + when: github_runner_arc_manage_secrets | bool - name: Install/upgrade each scale-set profile of {{ org.name }} ansible.builtin.include_tasks: install_scale_set_profile.yml diff --git a/roles/github_runner_arc/tasks/install_platform.yml b/roles/github_runner_arc/tasks/install_platform.yml index 640058d..e91fad7 100644 --- a/roles/github_runner_arc/tasks/install_platform.yml +++ b/roles/github_runner_arc/tasks/install_platform.yml @@ -9,7 +9,7 @@ kind: Namespace metadata: name: "{{ item }}" - loop: "{{ [github_runner_arc_platform_namespace, 'actions-runner-controller'] + ([github_runner_arc_fleet.autoscaled.namespace] if github_runner_arc_fleet.autoscaled else []) }}" + loop: "{{ [github_runner_arc_platform_namespace, github_runner_arc_controller_namespace] + ([github_runner_arc_fleet.autoscaled.namespace] if github_runner_arc_fleet.autoscaled else []) }}" - name: "Platform: create heartbeat/autoscaler service accounts" kubernetes.core.k8s: @@ -58,7 +58,7 @@ kind: Role metadata: name: github-runner-heartbeat-controller-reader - namespace: actions-runner-controller + namespace: "{{ github_runner_arc_controller_namespace }}" rules: - apiGroups: ["apps"] resources: ["deployments"] @@ -71,7 +71,7 @@ kind: RoleBinding metadata: name: github-runner-heartbeat-controller-reader - namespace: actions-runner-controller + namespace: "{{ github_runner_arc_controller_namespace }}" subjects: - kind: ServiceAccount name: heartbeat @@ -258,7 +258,7 @@ kubernetes.core.k8s: definition: "{{ lookup('ansible.builtin.template', 'image-pull-secret.yaml.j2', template_vars={'namespace': github_runner_arc_platform_namespace}) | from_yaml }}" no_log: true - when: github_runner_arc_manage_secrets | bool + when: github_runner_arc_manage_image_pull_secret | bool - name: "Platform: install/upgrade the heartbeat Deployment" kubernetes.core.k8s: @@ -284,20 +284,7 @@ containers: - name: heartbeat image: "{{ github_runner_arc_heartbeat_image }}" - env: - - name: HEARTBEAT_GIST_ID - value: "{{ github_runner_arc_heartbeat_gist_id }}" - - name: HEARTBEAT_WINDOW_SECONDS - value: "{{ github_runner_arc_heartbeat_window_seconds | string }}" - - name: HEARTBEAT_INTERVAL_SECONDS - value: "{{ github_runner_arc_heartbeat_interval_seconds | string }}" - - name: HEARTBEAT_STATE_NAMESPACE - value: "{{ github_runner_arc_platform_namespace }}" - - name: HEARTBEAT_GH_TOKEN - valueFrom: - secretKeyRef: - name: heartbeat-gh-token - key: token + env: "{{ github_runner_arc_heartbeat_env + github_runner_arc_heartbeat_controller_env }}" resources: requests: cpu: "50m" @@ -305,6 +292,27 @@ limits: cpu: "200m" memory: "64Mi" + vars: + github_runner_arc_heartbeat_env: + - name: HEARTBEAT_GIST_ID + value: "{{ github_runner_arc_heartbeat_gist_id }}" + - name: HEARTBEAT_WINDOW_SECONDS + value: "{{ github_runner_arc_heartbeat_window_seconds | string }}" + - name: HEARTBEAT_INTERVAL_SECONDS + value: "{{ github_runner_arc_heartbeat_interval_seconds | string }}" + - name: HEARTBEAT_STATE_NAMESPACE + value: "{{ github_runner_arc_platform_namespace }}" + - name: HEARTBEAT_GH_TOKEN + valueFrom: + secretKeyRef: + name: heartbeat-gh-token + key: token + # Where the heartbeat looks for the controller's Deployment. Passed only when it differs from scripts/heartbeat.sh's own default, so that a heartbeat Deployment installed before this setting existed is not rolled by an unchanged configuration. + github_runner_arc_heartbeat_controller_env: >- + {{ + [{'name': 'HEARTBEAT_CONTROLLER_NAMESPACE', 'value': github_runner_arc_controller_namespace}] + if github_runner_arc_controller_namespace != 'actions-runner-controller' else [] + }} - name: "Platform: install/upgrade the autoscaler Deployment" when: github_runner_arc_fleet.autoscaled is not none 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 1f83dcf..6f420b9 100644 --- a/roles/github_runner_arc/tasks/install_scale_set_profile.yml +++ b/roles/github_runner_arc/tasks/install_scale_set_profile.yml @@ -29,10 +29,10 @@ kubernetes.core.k8s: definition: "{{ lookup('ansible.builtin.template', 'image-pull-secret.yaml.j2', template_vars={'namespace': profile.namespace}) | from_yaml }}" no_log: true - when: github_runner_arc_manage_secrets | bool + when: github_runner_arc_manage_image_pull_secret | bool - 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: