Skip to content

feat: Enterprise-level runners - #5096

Open
dmitrykiselev27 wants to merge 2 commits into
github-aws-runners:mainfrom
dmitrykiselev27:enterprise-level-runner
Open

feat: Enterprise-level runners#5096
dmitrykiselev27 wants to merge 2 commits into
github-aws-runners:mainfrom
dmitrykiselev27:enterprise-level-runner

Conversation

@dmitrykiselev27

Copy link
Copy Markdown
Contributor

Description

Adds enterprise-level runner registration support. Previously, runners could only be registered at the repository or organization level using GitHub App authentication. This PR introduces a third registration level — enterprise — using PAT-based authentication against the GitHub Enterprise runner management APIs.

Key changes

Terraform

  • New runner_registration_level variable ("repo" | "org" | "enterprise") replaces the boolean enable_organization_runners
  • New enterprise_slug and enterprise_pat variables for enterprise configuration. Supports comma-separated multiple PATs for rate limit distribution.
  • github_app no longer requires key_base64 or id when runner_registration_level = "enterprise" — only webhook_secret is needed. No GitHub App creation required for enterprise runners.

Lambda functions

  • Enterprise code paths added for runner registration, listing, busy-state checks, de-registration, and termination — all using raw client.request() since Octokit's typed helpers don't cover enterprise endpoints
  • New createEnterprisePATClient() in auth.ts reads PAT from SSM and randomly selects one token from a comma-separated list per invocation
  • New resolveRunnerType() helper reads RUNNER_REGISTRATION_LEVEL env var with backward-compatible fallback to the legacy ENABLE_ORGANIZATION_RUNNERS

Test Plan

  1. Create PAT with the manage_runners:enterprise scope.
  2. Create webhook on Enterprise level. Events: select Workflow jobs only
  3. Set required Terraform variables:
runner_registration_level = "enterprise"
enterprise_slug = "<your-enterprise-slug>"   
enterprise_pat = {
  pat = "<your-PAT>"                                  
}
  1. Deploy the module
  2. Trigger GHA workflow to test

Related Issues

@dmitrykiselev27
dmitrykiselev27 force-pushed the enterprise-level-runner branch 3 times, most recently from 669630b to 1313567 Compare April 8, 2026 16:42
@edersonbrilhante

Copy link
Copy Markdown
Contributor

What is the rate limit for this type of pat? Maybe allow multiple pat and set some round robin logic. There is an open pr to support multiple apps for this reason.

@dmitrykiselev27

Copy link
Copy Markdown
Contributor Author

@edersonbrilhante as usual, 5000 requests/hour. For this reason, in this implementation pat string can be a comma-separated list.

When a function creates EnterprisePATClient it takes a random token from the list.
It's not the most clever logic, I know, but to properly implement round robin logic, I would need to store some state outside of Lambda function (there is no guarantee Lambda will reuse container for the next execution, also there may be multiple concurrent executions). But I'm open to feedback on this topic

@dmitrykiselev27
dmitrykiselev27 force-pushed the enterprise-level-runner branch from 36fd927 to 8b9e1c7 Compare April 9, 2026 08:44
@dmitrykiselev27
dmitrykiselev27 marked this pull request as ready for review April 9, 2026 09:20
@dmitrykiselev27
dmitrykiselev27 requested review from a team as code owners April 9, 2026 09:20
@Brend-Smits

Copy link
Copy Markdown
Contributor

What's the reason for supporting PAT's and not just enterprise github app's?

@dmitrykiselev27

Copy link
Copy Markdown
Contributor Author

@Brend-Smits unfortunately, GitHub App on enterprise level does not allow to manage runners.

image

@Brend-Smits

Copy link
Copy Markdown
Contributor

@Brend-Smits unfortunately, GitHub App on enterprise level does not allow to manage runners.

image

Okay thanks! I will try to arrange a test enterprise to be able to test this and get back to you 👍🏼

@dmitrykiselev27
dmitrykiselev27 force-pushed the enterprise-level-runner branch 3 times, most recently from 5ee5f67 to 8113d6c Compare May 15, 2026 16:45
Comment thread examples/default/main.tf

enable_organization_runners = true
runner_extra_labels = ["default", "example"]
runner_registration_level = "org"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like this change, but maybe instead of completely removing the 'enable_organization_runners' and making this a breaking change, instead we can just deprecate that option and move people to this new option instead?
In the mean time we can keep the old variable working. If that is used, then it takes priority or something?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes sense. Deprecated enable_organization_runners instead of breaking it: it still works, and if set to true, it takes priority over the new runner_registration_level for backwards compatibility.

@edersonbrilhante edersonbrilhante left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@dmitrykiselev27 can you fix the conflicts?

@dmitrykiselev27
dmitrykiselev27 force-pushed the enterprise-level-runner branch 3 times, most recently from 6b14465 to f473616 Compare July 16, 2026 13:43
@dmitrykiselev27

Copy link
Copy Markdown
Contributor Author

@edersonbrilhante rebased and fixed the conflicts

@edersonbrilhante

Copy link
Copy Markdown
Contributor

"Commits must have verified signatures."

Can you fix please?

@edersonbrilhante
edersonbrilhante self-requested a review July 16, 2026 19:24
@dmitrykiselev27
dmitrykiselev27 force-pushed the enterprise-level-runner branch 2 times, most recently from 9f3b3cd to ba83816 Compare July 17, 2026 12:18
@dmitrykiselev27

Copy link
Copy Markdown
Contributor Author

@edersonbrilhante fixed

@edersonbrilhante

Copy link
Copy Markdown
Contributor

I think is good. But I don't have enterprise env to test. Have you tested after fixing the conflicts?

@dmitrykiselev27

Copy link
Copy Markdown
Contributor Author

Yes, I've deployed my branch to an enterprise I have access to. I'm happy to provide test evidences in case you want to check something in particular
image

@dmitrykiselev27
dmitrykiselev27 force-pushed the enterprise-level-runner branch from ba83816 to 991850d Compare July 21, 2026 13:56
@dmitrykiselev27
dmitrykiselev27 force-pushed the enterprise-level-runner branch from 5310e90 to 5a7fb68 Compare July 21, 2026 13:58
Copilot AI lite review requested due to automatic review settings July 22, 2026 11:38

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review is ineligible. To be eligible to request a review, you need a paid Copilot license, or your organization must enable Copilot code review.

@edersonbrilhante

Copy link
Copy Markdown
Contributor

You moved the policy from json to data. This is a not problem for me, but it created a mixed approach when compared with other modules. I personally prefer data, but I think this could be a following PR.

@edersonbrilhante

Copy link
Copy Markdown
Contributor

Could we move this nonsensitive() change to a separate PR? It appears unrelated to enterprise runner support and adds noise to an already large change. If it is required for this feature, please document the specific Terraform error or sensitive-value propagation it fixes.

@edersonbrilhante

Copy link
Copy Markdown
Contributor

@dmitrykiselev27 Can you fix the conflicts? Heads up, I am working to refactor the module to introduce plugin layout. Ref: #5234

Copilot AI review requested due to automatic review settings August 4, 2026 13:55
@dmitrykiselev27
dmitrykiselev27 force-pushed the enterprise-level-runner branch from 739c2e2 to b0647c3 Compare August 4, 2026 13:56
@dmitrykiselev27
dmitrykiselev27 force-pushed the enterprise-level-runner branch 2 times, most recently from b246aa5 to a16eb9e Compare August 4, 2026 14:01

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 62 out of 63 changed files in this pull request and generated no new comments.

Suppressed comments (6)

modules/multi-runner/variables.tf:45

  • enterprise_slug is documented as required when runner_registration_level is "enterprise", but the module does not currently enforce that relationship. Since multi_runner_config can now opt into enterprise registration, validate that enterprise_slug is set whenever any runner config resolves to enterprise.
    modules/multi-runner/variables.tf:71
  • enterprise_pat is documented as required when runner_registration_level is "enterprise", but the module does not enforce that it’s set when any runner opts into enterprise registration. Without this, enterprise deployments can plan/apply successfully but fail at runtime when the lambdas try to create an enterprise PAT client.
    lambdas/functions/control-plane/src/scale-runners/runner-config.ts:13
  • resolveRunnerType() only considers RUNNER_REGISTRATION_LEVEL and defaults to Repo when it’s unset. The PR description says there’s a backward-compatible fallback to the legacy ENABLE_ORGANIZATION_RUNNERS env var, but the current implementation ignores it entirely (and production code no longer reads ENABLE_ORGANIZATION_RUNNERS), which can change behavior for existing deployments that haven’t been updated to set RUNNER_REGISTRATION_LEVEL.
export function resolveRunnerType(): RunnerType {
  const registrationLevel = process.env.RUNNER_REGISTRATION_LEVEL;
  if (registrationLevel) {
    switch (registrationLevel) {
      case 'enterprise':

lambdas/functions/control-plane/src/scale-runners/job-retry.ts:47

  • In enterprise mode, runnerOwner falls back to an empty string when ENTERPRISE_SLUG is missing. That will later be used as the GitHub owner and logged/metric-tagged, and will likely cause confusing API calls (and errors) compared to scale-up/pool which explicitly fail fast when ENTERPRISE_SLUG is missing.
  const runnerType = resolveRunnerType();
  const enableOrgLevel = runnerType !== 'Repo';
  const runnerOwner =
    runnerType === 'Enterprise'
      ? (process.env.ENTERPRISE_SLUG ?? '')

modules/multi-runner/variables.tf:33

  • The multi-runner module now allows omitting github_app.id/key_base64 entirely (it only validates webhook_secret). If any runner in multi_runner_config registers at repo/org level, missing id/key_base64 will lead to runtime failures in the lambdas. Consider validating that id+key_base64 are provided whenever any runner_registration_level resolves to repo/org (and allow them to be omitted only when all runners are enterprise-registered).

This issue also appears in the following locations of the same file:

  • line 41
  • line 67
    lambdas/functions/control-plane/src/pool/pool.ts:51
  • Use strict equality for the runnerType check to avoid implicit coercions and to match the rest of the codebase’s style (runnerType is a string union).
  if (runnerType == 'Enterprise' && !enterpriseSlug) {
    throw new Error('ENTERPRISE_SLUG must be set when RUNNER_REGISTRATION_LEVEL is enterprise.');

Copilot AI review requested due to automatic review settings August 4, 2026 14:03

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 62 out of 63 changed files in this pull request and generated no new comments.

Suppressed comments (5)

lambdas/functions/control-plane/src/scale-runners/runner-config.ts:7

  • The PR description says resolveRunnerType is backward-compatible via a fallback to the legacy ENABLE_ORGANIZATION_RUNNERS env var, but this implementation only checks RUNNER_REGISTRATION_LEVEL and otherwise always defaults to Repo. This can break older deployments that still set ENABLE_ORGANIZATION_RUNNERS (or tests/tools that rely on it).
/**
 * Resolves the RunnerType from the RUNNER_REGISTRATION_LEVEL environment variable.
 *
 * Valid values: 'enterprise' | 'org' | 'repo'.
 * Defaults to 'Repo' when not set.

lambdas/functions/control-plane/src/pool/pool.ts:51

  • Use strict equality here; runnerType is a string union, so === avoids accidental coercion and matches the rest of the codebase’s comparisons.
  if (runnerType == 'Enterprise' && !enterpriseSlug) {
    throw new Error('ENTERPRISE_SLUG must be set when RUNNER_REGISTRATION_LEVEL is enterprise.');
  }

modules/ssm/variables.tf:69

  • modules/ssm allows enterprise_pat = {} (both pat and pat_ssm null) because there is no validation. In that case, aws_ssm_parameter.enterprise_pat has count = 0 but modules/ssm/outputs.tf still references aws_ssm_parameter.enterprise_pat[0], which will fail the plan/apply.
    lambdas/functions/control-plane/src/pool/pool.ts:140
  • For repo-level registration, getInstallationId assumes RUNNER_OWNER is in "owner/repo" form. If it’s missing or malformed, owner/repo become undefined and the subsequent GitHub API call will fail with a confusing error. Please validate the format and throw a clear message.
  const [owner, repo] = runnerOwner.split('/');
  return (
    await githubClient.apps.getRepoInstallation({
      owner,
      repo,

main.tf:67

  • pool_runner_owner can still be null for non-enterprise stacks (e.g., default repo-level) even when pool_config is set. That results in the pool Lambda being deployed with RUNNER_OWNER="" and failing at runtime (pool.ts throws when RUNNER_OWNER is missing for org/repo levels). Add a Terraform precondition to fail fast when pool_config is non-empty and (a) registration level is repo, or (b) registration level is org and pool_runner_owner is not set.

Copilot AI review requested due to automatic review settings August 4, 2026 14:37
@dmitrykiselev27
dmitrykiselev27 force-pushed the enterprise-level-runner branch from a16eb9e to c80394e Compare August 4, 2026 14:37

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 62 out of 63 changed files in this pull request and generated 1 comment.

Suppressed comments (4)

lambdas/functions/control-plane/src/pool/pool.ts:49

  • Use strict equality for runnerType comparisons to avoid unintended coercion and to match the rest of the codebase’s TypeScript style.
  if (runnerType == 'Enterprise' && !enterpriseSlug) {

modules/multi-runner/variables.tf:33

  • The github_app variable validation was relaxed to only require the webhook secret. This allows invalid org/repo configurations (missing GitHub App id/key) to pass Terraform validation and fail later at runtime. Since multi-runner defaults to repo/org registration unless explicitly set to enterprise, it should still enforce id/key unless all runner configs are enterprise.
    modules/ssm/variables.tf:69
  • modules/ssm.enterprise_pat lacks a validation block ensuring at least one of pat or pat_ssm is set. If a caller passes enterprise_pat = {} (or both fields null), the module will later fail (e.g., outputs.tf indexes enterprise_pat[0]). Adding validation here makes the module robust and consistent with the root module.
    modules/ssm/outputs.tf:24
  • enterprise_pat output unconditionally indexes aws_ssm_parameter.enterprise_pat[0] whenever enterprise_pat is non-null and pat_ssm is null. If enterprise_pat is provided but no parameter is created (e.g., invalid object / missing pat), this will cause a hard error. Add a length guard like the github_app_id/key outputs (and/or return null) to avoid index-out-of-range failures.

Comment on lines +9 to +26
export function resolveRunnerType(): RunnerType {
const registrationLevel = process.env.RUNNER_REGISTRATION_LEVEL;
if (registrationLevel) {
switch (registrationLevel) {
case 'enterprise':
return 'Enterprise';
case 'org':
return 'Org';
case 'repo':
return 'Repo';
default:
throw new Error(
`Invalid RUNNER_REGISTRATION_LEVEL: '${registrationLevel}'. Must be 'enterprise', 'org', or 'repo'.`,
);
}
}
return 'Repo';
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Backward compatibility implemented on the Terraform level

runners module

multi-runner

@dmitrykiselev27

Copy link
Copy Markdown
Contributor Author

@edersonbrilhante both changes (policy and nonsensitive) were attempts to fix this error, which I'm not longer can reproduce in my test environment, so I've reverted them.

  │  122:   policy = templatefile("${path.module}/policies/lambda-scale-up.json", {
  │  123:     arn_runner_instance_role  = aws_iam_role.runner.arn
  │  124:     sqs_arn                   = var.sqs_build_queue.arn
  │  125:     github_app_id_arn         = nonsensitive(var.github_app_parameters.id.arn)
  │  126:     github_app_key_base64_arn = nonsensitive(var.github_app_parameters.key_base64.arn)
  │  127:     ssm_config_path           = local.arn_ssm_parameters_path_config
  │  128:     kms_key_arn               = local.kms_key_arn
  │  129:     ami_kms_key_arn           = local.ami_kms_key_arn
  │  130:     ssm_ami_id_parameter_arn  = local.ami_id_ssm_module_managed ? local.arn_ssm_parameter_ami_id : var.ami.id_ssm_parameter_arn
  │  131:     enterprise_pat_arn        = var.enterprise_pat_parameter != null ? nonsensitive(var.enterprise_pat_parameter.arn) : ""
  │  132:   })
  │ 
  │ Call to function "templatefile" failed: function returned an inconsistent
  │ result.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants