diff --git a/README.md b/README.md index 351a160a62..2fed5e2fc0 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ This [Terraform](https://www.terraform.io/) module creates the required infrastr - OS support: Linux (x64/arm64) and Windows - Multi-Runner: Create multiple runner configurations with a single deployment - GitHub cloud, GitHub Cloud with Data Residency and GitHub Enterprise Server (GHES) support. -- Org and repo level runners. enterprise level runners are not supported (yet). +- Org and repo level runners. Enterprise level runners are supported via PAT-based authentication. ## Getting started @@ -77,6 +77,7 @@ Join our discord community via [this invite link](https://discord.gg/bxgXW8jJGh) |------|---------| | [aws](#provider\_aws) | >= 6.21 | | [random](#provider\_random) | ~> 3.0 | +| [terraform](#provider\_terraform) | n/a | ## Modules @@ -98,6 +99,7 @@ Join our discord community via [this invite link](https://discord.gg/bxgXW8jJGh) | [aws_sqs_queue_policy.build_queue_dlq_policy](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/sqs_queue_policy) | resource | | [aws_sqs_queue_policy.build_queue_policy](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/sqs_queue_policy) | resource | | [random_string.random](https://registry.terraform.io/providers/hashicorp/random/latest/docs/resources/string) | resource | +| [terraform_data.enterprise_validation](https://registry.terraform.io/providers/hashicorp/terraform/latest/docs/resources/data) | resource | | [aws_iam_policy_document.deny_insecure_transport](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | ## Inputs @@ -125,7 +127,7 @@ Join our discord community via [this invite link](https://discord.gg/bxgXW8jJGh) | [enable\_jit\_config](#input\_enable\_jit\_config) | Overwrite the default behavior for JIT configuration. By default JIT configuration is enabled for ephemeral runners and disabled for non-ephemeral runners. In case of GHES check first if the JIT config API is available. In case you are upgrading from 3.x to 4.x you can set `enable_jit_config` to `false` to avoid a breaking change when having your own AMI. | `bool` | `null` | no | | [enable\_job\_queued\_check](#input\_enable\_job\_queued\_check) | Only scale if the job event received by the scale up lambda is in the queued state. By default enabled for non ephemeral runners and disabled for ephemeral. Set this variable to overwrite the default behavior. | `bool` | `null` | no | | [enable\_managed\_runner\_security\_group](#input\_enable\_managed\_runner\_security\_group) | Enables creation of the default managed security group. Unmanaged security groups can be specified via `runner_additional_security_group_ids`. | `bool` | `true` | no | -| [enable\_organization\_runners](#input\_enable\_organization\_runners) | Register runners to organization, instead of repo level | `bool` | `false` | no | +| [enable\_organization\_runners](#input\_enable\_organization\_runners) | DEPRECATED: Use `runner_registration_level` instead. Register runners to organization (true) or repository (false). | `bool` | `false` | no | | [enable\_runner\_binaries\_syncer](#input\_enable\_runner\_binaries\_syncer) | Option to disable the lambda to sync GitHub runner distribution, useful when using a pre-build AMI. | `bool` | `true` | no | | [enable\_runner\_detailed\_monitoring](#input\_enable\_runner\_detailed\_monitoring) | Should detailed monitoring be enabled for the runner. Set this to true if you want to use detailed monitoring. See https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/using-cloudwatch-new.html for details. | `bool` | `false` | no | | [enable\_runner\_on\_demand\_failover\_for\_errors](#input\_enable\_runner\_on\_demand\_failover\_for\_errors) | Enable on-demand failover. For example to fall back to on demand when no spot capacity is available the variable can be set to `InsufficientInstanceCapacity`. When not defined the default behavior is to retry later. | `list(string)` | `[]` | no | @@ -133,10 +135,12 @@ Join our discord community via [this invite link](https://discord.gg/bxgXW8jJGh) | [enable\_ssm\_on\_runners](#input\_enable\_ssm\_on\_runners) | Enable to allow access to the runner instances for debugging purposes via SSM. Note that this adds additional permissions to the runner instances. | `bool` | `false` | no | | [enable\_user\_data\_debug\_logging\_runner](#input\_enable\_user\_data\_debug\_logging\_runner) | Option to enable debug logging for user-data, this logs all secrets as well. | `bool` | `false` | no | | [enable\_userdata](#input\_enable\_userdata) | Should the userdata script be enabled for the runner. Set this to false if you are using your own prebuilt AMI. | `bool` | `true` | no | +| [enterprise\_pat](#input\_enterprise\_pat) | Enterprise Personal Access Token(s) (PAT) for authenticating with GitHub Enterprise runner management APIs.
Required when runner\_registration\_level is "enterprise". Each PAT must have the 'manage\_runners:enterprise' scope.
You can either provide the PAT value directly (Terraform creates the SSM parameter) or reference a pre-existing SSM parameter.
Note: the provided SSM parameter arn and name take precedence over the direct value.

To distribute API calls across multiple PATs and avoid rate limiting, provide a comma-separated list of PATs
in the 'pat' field or in the SSM parameter value. The Lambda functions will randomly select one PAT per invocation.
Example: enterprise\_pat = { pat = "ghp\_token1,ghp\_token2,ghp\_token3" } |
object({
pat = optional(string)
pat_ssm = optional(object({
arn = string
name = string
}))
})
| `null` | no | +| [enterprise\_slug](#input\_enterprise\_slug) | The slug (URL identifier) of the GitHub Enterprise account. Required when runner\_registration\_level is "enterprise". Example: "my-enterprise". | `string` | `null` | no | | [eventbridge](#input\_eventbridge) | Enable the use of EventBridge by the module. By enabling this feature events will be put on the EventBridge by the webhook instead of directly dispatching to queues for scaling.

`enable`: Enable the EventBridge feature.
`accept_events`: List can be used to only allow specific events to be putted on the EventBridge. By default all events, empty list will be be interpreted as all events. |
object({
enable = optional(bool, true)
accept_events = optional(list(string), null)
})
| `{}` | no | | [ghes\_ssl\_verify](#input\_ghes\_ssl\_verify) | GitHub Enterprise SSL verification. Set to 'false' when custom certificate (chains) is used for GitHub Enterprise Server (insecure). | `bool` | `true` | no | | [ghes\_url](#input\_ghes\_url) | GitHub Enterprise Server URL. Example: https://github.internal.co - DO NOT SET IF USING PUBLIC GITHUB. However if you are using GitHub Enterprise Cloud with data-residency (ghe.com), set the endpoint here. Example - https://companyname.ghe.com | `string` | `null` | no | -| [github\_app](#input\_github\_app) | GitHub app parameters, see your github app.
You can optionally create the SSM parameters yourself and provide the ARN and name here, through the `*_ssm` attributes.
If you chose to provide the configuration values directly here,
please ensure the key is the base64-encoded `.pem` file (the output of `base64 app.private-key.pem`, not the content of `private-key.pem`).
Note: the provided SSM parameters arn and name have a precedence over the actual value (i.e `key_base64_ssm` has a precedence over `key_base64` etc). |
object({
key_base64 = optional(string)
key_base64_ssm = optional(object({
arn = string
name = string
}))
id = optional(string)
id_ssm = optional(object({
arn = string
name = string
}))
webhook_secret = optional(string)
webhook_secret_ssm = optional(object({
arn = string
name = string
}))
})
| n/a | yes | +| [github\_app](#input\_github\_app) | GitHub app parameters, see your github app.
You can optionally create the SSM parameters yourself and provide the ARN and name here, through the `*_ssm` attributes.
If you chose to provide the configuration values directly here,
please ensure the key is the base64-encoded `.pem` file (the output of `base64 app.private-key.pem`, not the content of `private-key.pem`).
Note: the provided SSM parameters arn and name have a precedence over the actual value (i.e `key_base64_ssm` has a precedence over `key_base64` etc).

For enterprise runners (runner\_registration\_level = "enterprise"), only `webhook_secret` (or `webhook_secret_ssm`) is required.
The `key_base64` and `id` fields are only needed for org/repo level runners. |
object({
key_base64 = optional(string)
key_base64_ssm = optional(object({
arn = string
name = string
}))
id = optional(string)
id_ssm = optional(object({
arn = string
name = string
}))
webhook_secret = optional(string)
webhook_secret_ssm = optional(object({
arn = string
name = string
}))
})
| n/a | yes | | [idle\_config](#input\_idle\_config) | List of time periods, defined as a cron expression, to keep a minimum amount of runners active instead of scaling down to 0. By defining this list you can ensure that in time periods that match the cron expression within 5 seconds a runner is kept idle. |
list(object({
cron = string
timeZone = string
idleCount = number
evictionStrategy = optional(string, "oldest_first")
}))
| `[]` | no | | [instance\_allocation\_strategy](#input\_instance\_allocation\_strategy) | The allocation strategy for spot instances. AWS recommends using `price-capacity-optimized` however the AWS default is `lowest-price`. | `string` | `"lowest-price"` | no | | [instance\_max\_spot\_price](#input\_instance\_max\_spot\_price) | Max price price for spot instances per hour. This variable will be passed to the create fleet as max spot price for the fleet. | `string` | `null` | no | @@ -169,7 +173,7 @@ Join our discord community via [this invite link](https://discord.gg/bxgXW8jJGh) | [pool\_lambda\_memory\_size](#input\_pool\_lambda\_memory\_size) | Memory size limit for scale-up lambda. | `number` | `512` | no | | [pool\_lambda\_reserved\_concurrent\_executions](#input\_pool\_lambda\_reserved\_concurrent\_executions) | Amount of reserved concurrent executions for the scale-up lambda function. A value of 0 disables lambda from being triggered and -1 removes any concurrency limitations. | `number` | `1` | no | | [pool\_lambda\_timeout](#input\_pool\_lambda\_timeout) | Time out for the pool lambda in seconds. | `number` | `60` | no | -| [pool\_runner\_owner](#input\_pool\_runner\_owner) | The pool will deploy runners to the GitHub org ID, set this value to the org to which you want the runners deployed. Repo level is not supported. | `string` | `null` | no | +| [pool\_runner\_owner](#input\_pool\_runner\_owner) | The pool will deploy runners to the GitHub org ID, set this value to the org to which you want the runners deployed. Repo level is not supported. For enterprise-level runners, defaults to the enterprise\_slug if not set. | `string` | `null` | no | | [prefix](#input\_prefix) | The prefix used for naming resources | `string` | `"github-actions"` | no | | [queue\_encryption](#input\_queue\_encryption) | Configure how data on queues managed by the modules is encrypted at REST. Options are encrypted via SSE, non encrypted and via KMS. By default encrypted via SSE is enabled. See for more details the Terraform `aws_sqs_queue` resource https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/sqs_queue. |
object({
kms_data_key_reuse_period_seconds = number
kms_master_key_id = string
sqs_managed_sse_enabled = bool
})
|
{
"kms_data_key_reuse_period_seconds": null,
"kms_master_key_id": null,
"sqs_managed_sse_enabled": true
}
| no | | [redrive\_build\_queue](#input\_redrive\_build\_queue) | Set options to attach (optional) a dead letter queue to the build queue, the queue between the webhook and the scale up lambda. You have the following options. 1. Disable by setting `enabled` to false. 2. Enable by setting `enabled` to `true`, `maxReceiveCount` to a number of max retries. |
object({
enabled = bool
maxReceiveCount = number
})
|
{
"enabled": false,
"maxReceiveCount": null
}
| no | @@ -203,6 +207,7 @@ Join our discord community via [this invite link](https://discord.gg/bxgXW8jJGh) | [runner\_name\_prefix](#input\_runner\_name\_prefix) | The prefix used for the GitHub runner name. The prefix will be used in the default start script to prefix the instance name when register the runner in GitHub. The value is available via an EC2 tag 'ghr:runner\_name\_prefix'. | `string` | `""` | no | | [runner\_os](#input\_runner\_os) | The EC2 Operating System type to use for action runner instances (linux,windows). | `string` | `"linux"` | no | | [runner\_placement](#input\_runner\_placement) | The placement options for the instance. See https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/launch_template#placement for details. |
object({
affinity = optional(string)
availability_zone = optional(string)
group_id = optional(string)
group_name = optional(string)
host_id = optional(string)
host_resource_group_arn = optional(string)
spread_domain = optional(string)
tenancy = optional(string)
partition_number = optional(number)
})
| `null` | no | +| [runner\_registration\_level](#input\_runner\_registration\_level) | The level at which runners are registered in GitHub. Valid values: "repo", "org", "enterprise". Defaults to "repo" when not set. | `string` | `null` | no | | [runner\_run\_as](#input\_runner\_run\_as) | Run the GitHub actions agent as user. | `string` | `"ec2-user"` | no | | [runners\_ebs\_optimized](#input\_runners\_ebs\_optimized) | Enable EBS optimization for the runner instances. | `bool` | `false` | no | | [runners\_lambda\_s3\_key](#input\_runners\_lambda\_s3\_key) | S3 key for runners lambda function. Required if using S3 bucket to specify lambdas. | `string` | `null` | no | diff --git a/docs/examples/enterprise.md b/docs/examples/enterprise.md new file mode 100644 index 0000000000..7ef2a9e9c6 --- /dev/null +++ b/docs/examples/enterprise.md @@ -0,0 +1 @@ +--8<-- "examples/enterprise/README.md" diff --git a/docs/getting-started.md b/docs/getting-started.md index 753d623367..b86e0875f9 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -91,7 +91,8 @@ module "github-runner" { webhook_lambda_zip = "lambdas-download/webhook.zip" runner_binaries_syncer_lambda_zip = "lambdas-download/runner-binaries-syncer.zip" runners_lambda_zip = "lambdas-download/runners.zip" - enable_organization_runners = true + + runner_registration_level = "org" } ``` diff --git a/examples/default/main.tf b/examples/default/main.tf index 2addb372a4..1664379775 100644 --- a/examples/default/main.tf +++ b/examples/default/main.tf @@ -50,8 +50,8 @@ module "runners" { # runner_binaries_syncer_lambda_zip = "../lambdas-download/runner-binaries-syncer.zip" # runners_lambda_zip = "../lambdas-download/runners.zip" - enable_organization_runners = true - runner_extra_labels = ["default", "example"] + runner_registration_level = "org" + runner_extra_labels = ["default", "example"] # enable access to the runners via SSM enable_ssm_on_runners = true diff --git a/examples/enterprise/README.md b/examples/enterprise/README.md new file mode 100644 index 0000000000..0e07278eb7 --- /dev/null +++ b/examples/enterprise/README.md @@ -0,0 +1,129 @@ +# Enterprise Runner Example + +This example demonstrates how to deploy GitHub self-hosted runners at the **Enterprise level** using PAT-based authentication. + +## Prerequisites + +1. **GitHub Enterprise Account** — You need a GitHub Enterprise Cloud account with admin access. +2. **Enterprise PAT** — Create one or more Personal Access Tokens with the `manage_runners:enterprise` scope: + - Go to your GitHub account settings → Developer settings → Personal access tokens (classic) + - Create a new token with the `manage_runners:enterprise` scope + - Save the token securely — you'll need it for the `enterprise_pat` variable + - **Tip**: To distribute API calls and avoid rate limiting, create multiple PATs (from different accounts if possible) and provide them as a comma-separated string. The Lambda functions will randomly select one PAT per invocation. +3. **Enterprise Webhook** — Configure an enterprise-level webhook to send `workflow_job` events to the module's webhook endpoint. Choose a random secret for the webhook — you'll need it for the `webhook_secret` parameter. + +> **Note**: Enterprise runners do **not** require a GitHub App. Only a webhook secret is needed to verify incoming webhook payloads. The PAT handles all GitHub API interactions. + +## Configuration + +```hcl +module "runners" { + source = "../../" + + # Enterprise runner registration + runner_registration_level = "enterprise" + enterprise_slug = "my-enterprise" + + # Enterprise PAT — stored in AWS SSM Parameter Store as SecureString + # Single PAT: + enterprise_pat = { + pat = var.enterprise_pat + } + + # Multiple PATs (comma-separated) for rate limit distribution: + # enterprise_pat = { + # pat = "ghp_token1,ghp_token2,ghp_token3" + # } + + # No GitHub App is required for enterprise runners. + # Only the webhook_secret is needed to verify incoming webhook payloads. + github_app = { + webhook_secret = random_id.random.hex + } + + # ... other configuration +} +``` + +## Variables + +| Name | Description | Type | Required | +|------|-------------|------|----------| +| `enterprise_slug` | The slug of the GitHub Enterprise account | string | Yes | +| `enterprise_pat` | PAT with `manage_runners:enterprise` scope | string (sensitive) | Yes | +| `environment` | Environment name prefix | string | No | +| `aws_region` | AWS region for deployment | string | No | + +The `github_app` block only requires `webhook_secret` — the `key_base64` and `id` fields are **not** needed for enterprise runners. + +## Verification + +After deployment: + +1. Check the webhook endpoint in the Terraform outputs +2. Configure the enterprise webhook to point to the endpoint +3. Trigger a workflow run in any repository under the enterprise +4. Verify runners appear in **Enterprise Settings → Actions → Runners** + +## Migration from Organization Runners + +If you're migrating from organization-level runners: + +```hcl +# Before +enable_organization_runners = true + +# After +runner_registration_level = "enterprise" +enterprise_slug = "my-enterprise" +enterprise_pat = { + pat = var.enterprise_pat +} +``` + + +## Requirements + +| Name | Version | +|------|---------| +| [terraform](#requirement\_terraform) | >= 1.3.0 | +| [aws](#requirement\_aws) | >= 6.21 | +| [local](#requirement\_local) | ~> 2.0 | +| [random](#requirement\_random) | ~> 3.0 | + +## Providers + +| Name | Version | +|------|---------| +| [random](#provider\_random) | ~> 3.0 | + +## Modules + +| Name | Source | Version | +|------|--------|---------| +| [base](#module\_base) | ../base | n/a | +| [runners](#module\_runners) | ../../ | n/a | + +## Resources + +| Name | Type | +|------|------| +| [random_id.random](https://registry.terraform.io/providers/hashicorp/random/latest/docs/resources/id) | resource | + +## Inputs + +| Name | Description | Type | Default | Required | +|------|-------------|------|---------|:--------:| +| [aws\_region](#input\_aws\_region) | AWS region. | `string` | `"eu-west-1"` | no | +| [enterprise\_pat](#input\_enterprise\_pat) | Personal Access Token with 'manage\_runners:enterprise' scope for enterprise runner management. | `string` | n/a | yes | +| [enterprise\_slug](#input\_enterprise\_slug) | The slug of the GitHub Enterprise account. Example: 'my-enterprise'. | `string` | n/a | yes | +| [environment](#input\_environment) | Environment name, used as prefix. | `string` | `null` | no | + +## Outputs + +| Name | Description | +|------|-------------| +| [runners](#output\_runners) | n/a | +| [webhook\_endpoint](#output\_webhook\_endpoint) | n/a | +| [webhook\_secret](#output\_webhook\_secret) | n/a | + \ No newline at end of file diff --git a/examples/enterprise/main.tf b/examples/enterprise/main.tf new file mode 100644 index 0000000000..089640533b --- /dev/null +++ b/examples/enterprise/main.tf @@ -0,0 +1,61 @@ +locals { + environment = var.environment != null ? var.environment : "enterprise" + aws_region = var.aws_region +} + +resource "random_id" "random" { + byte_length = 20 +} + +module "base" { + source = "../base" + + prefix = local.environment + aws_region = local.aws_region +} + +module "runners" { + source = "../../" + create_service_linked_role_spot = true + aws_region = local.aws_region + vpc_id = module.base.vpc.vpc_id + subnet_ids = module.base.vpc.private_subnets + + prefix = local.environment + tags = { + Project = "Enterprise Runners" + } + + # Enterprise runners do not require a GitHub App. + # Only the webhook_secret is needed to verify incoming webhook payloads. + github_app = { + webhook_secret = random_id.random.hex + } + + # Enterprise runner registration level + runner_registration_level = "enterprise" + enterprise_slug = var.enterprise_slug + + # Enterprise PAT for authentication + enterprise_pat = { + pat = var.enterprise_pat + } + + # Runner labels + runner_extra_labels = ["enterprise", "example"] + + # enable access to the runners via SSM + enable_ssm_on_runners = true + + instance_types = ["m7a.large", "m5.large"] + + # override delay of events in seconds + delay_webhook_event = 5 + runners_maximum_count = 5 + + # override scaling down + scale_down_schedule_expression = "cron(* * * * ? *)" + + enable_user_data_debug_logging_runner = true +} + diff --git a/examples/enterprise/outputs.tf b/examples/enterprise/outputs.tf new file mode 100644 index 0000000000..c50214f566 --- /dev/null +++ b/examples/enterprise/outputs.tf @@ -0,0 +1,15 @@ +output "runners" { + value = { + lambda_syncer_name = module.runners.binaries_syncer.lambda.function_name + } +} + +output "webhook_endpoint" { + value = module.runners.webhook.endpoint +} + +output "webhook_secret" { + sensitive = true + value = random_id.random.hex +} + diff --git a/examples/enterprise/providers.tf b/examples/enterprise/providers.tf new file mode 100644 index 0000000000..9bd978aeb0 --- /dev/null +++ b/examples/enterprise/providers.tf @@ -0,0 +1,10 @@ +provider "aws" { + region = local.aws_region + + default_tags { + tags = { + Example = local.environment + } + } +} + diff --git a/examples/enterprise/variables.tf b/examples/enterprise/variables.tf new file mode 100644 index 0000000000..83148920e2 --- /dev/null +++ b/examples/enterprise/variables.tf @@ -0,0 +1,23 @@ +variable "enterprise_slug" { + description = "The slug of the GitHub Enterprise account. Example: 'my-enterprise'." + type = string +} + +variable "enterprise_pat" { + description = "Personal Access Token with 'manage_runners:enterprise' scope for enterprise runner management." + type = string + sensitive = true +} + +variable "environment" { + description = "Environment name, used as prefix." + type = string + default = null +} + +variable "aws_region" { + description = "AWS region." + type = string + default = "eu-west-1" +} + diff --git a/examples/enterprise/versions.tf b/examples/enterprise/versions.tf new file mode 100644 index 0000000000..b1bb3aab28 --- /dev/null +++ b/examples/enterprise/versions.tf @@ -0,0 +1,18 @@ +terraform { + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 6.21" + } + local = { + source = "hashicorp/local" + version = "~> 2.0" + } + random = { + source = "hashicorp/random" + version = "~> 3.0" + } + } + required_version = ">= 1.3.0" +} + diff --git a/examples/ephemeral/main.tf b/examples/ephemeral/main.tf index 2b9403ca4d..36774e1074 100644 --- a/examples/ephemeral/main.tf +++ b/examples/ephemeral/main.tf @@ -40,8 +40,8 @@ module "runners" { # runner_binaries_syncer_lambda_zip = "../lambdas-download/runner-binaries-syncer.zip" # runners_lambda_zip = "../lambdas-download/runners.zip" - enable_organization_runners = true - runner_extra_labels = ["default", "example"] + runner_registration_level = "org" + runner_extra_labels = ["default", "example"] # enable access to the runners via SSM enable_ssm_on_runners = true diff --git a/examples/external-managed-ssm-secrets/main.tf b/examples/external-managed-ssm-secrets/main.tf index e390a56792..bed6e7cafa 100644 --- a/examples/external-managed-ssm-secrets/main.tf +++ b/examples/external-managed-ssm-secrets/main.tf @@ -28,8 +28,8 @@ module "runners" { webhook_secret_ssm = var.github_app_ssm_parameters.webhook_secret } - enable_organization_runners = true - runner_extra_labels = ["default", "example"] + runner_registration_level = "org" + runner_extra_labels = ["default", "example"] # enable access to the runners via SSM enable_ssm_on_runners = true diff --git a/examples/permissions-boundary/main.tf b/examples/permissions-boundary/main.tf index 3b7348ae8c..356845397c 100644 --- a/examples/permissions-boundary/main.tf +++ b/examples/permissions-boundary/main.tf @@ -55,7 +55,7 @@ module "runners" { webhook_lambda_zip = "../lambdas-download/webhook.zip" runner_binaries_syncer_lambda_zip = "../lambdas-download/runner-binaries-syncer.zip" runners_lambda_zip = "../lambdas-download/runners.zip" - enable_organization_runners = false + runner_registration_level = "repo" runner_extra_labels = ["default", "example"] instance_profile_path = "/runners/" diff --git a/examples/prebuilt/main.tf b/examples/prebuilt/main.tf index 62434f3f61..f44b3ec857 100644 --- a/examples/prebuilt/main.tf +++ b/examples/prebuilt/main.tf @@ -23,8 +23,8 @@ module "runners" { vpc_id = module.base.vpc.vpc_id subnet_ids = module.base.vpc.private_subnets - prefix = local.environment - enable_organization_runners = false + prefix = local.environment + runner_registration_level = "repo" github_app = { key_base64 = var.github_app.key_base64 diff --git a/lambdas/functions/control-plane/src/aws/runners.d.ts b/lambdas/functions/control-plane/src/aws/runners.d.ts index c891500f27..5d3b3a0e91 100644 --- a/lambdas/functions/control-plane/src/aws/runners.d.ts +++ b/lambdas/functions/control-plane/src/aws/runners.d.ts @@ -1,7 +1,7 @@ import { DefaultTargetCapacityType, SpotAllocationStrategy } from '@aws-sdk/client-ec2'; import { LambdaRunnerSource } from '../scale-runners/scale-up'; -export type RunnerType = 'Org' | 'Repo'; +export type RunnerType = 'Org' | 'Repo' | 'Enterprise'; export interface RunnerList { instanceId: string; @@ -10,6 +10,7 @@ export interface RunnerList { type?: string; repo?: string; org?: string; + enterprise?: string; orphan?: boolean; runnerId?: string; bypassRemoval?: boolean; @@ -34,6 +35,7 @@ export interface RunnerInputParameters { environment: string; runnerType: RunnerType; runnerOwner: string; + enterpriseSlug?: string; subnets: string[]; launchTemplateName: string; ec2instanceCriteria: { diff --git a/lambdas/functions/control-plane/src/aws/runners.ts b/lambdas/functions/control-plane/src/aws/runners.ts index 193c82d2e7..23dfe22ade 100644 --- a/lambdas/functions/control-plane/src/aws/runners.ts +++ b/lambdas/functions/control-plane/src/aws/runners.ts @@ -91,6 +91,7 @@ function getRunnerInfo(runningInstances: DescribeInstancesResult) { type: i.Tags?.find((e) => e.Key === 'ghr:Type')?.Value as string, repo: i.Tags?.find((e) => e.Key === 'ghr:Repo')?.Value as string, org: i.Tags?.find((e) => e.Key === 'ghr:Org')?.Value as string, + enterprise: i.Tags?.find((e) => e.Key === 'ghr:enterprise')?.Value as string, orphan: i.Tags?.find((e) => e.Key === 'ghr:orphan')?.Value === 'true', runnerId: i.Tags?.find((e) => e.Key === 'ghr:github_runner_id')?.Value as string, bypassRemoval: i.Tags?.find((e) => e.Key === 'ghr:bypass-removal')?.Value === 'true', @@ -244,6 +245,9 @@ async function createInstances( { Key: 'ghr:created_by', Value: runnerParameters.source }, { Key: 'ghr:Type', Value: runnerParameters.runnerType }, { Key: 'ghr:Owner', Value: runnerParameters.runnerOwner }, + ...(runnerParameters.runnerType === 'Enterprise' && runnerParameters.enterpriseSlug + ? [{ Key: 'ghr:enterprise', Value: runnerParameters.enterpriseSlug }] + : []), ]; if (runnerParameters.tracingEnabled) { diff --git a/lambdas/functions/control-plane/src/github/auth.test.ts b/lambdas/functions/control-plane/src/github/auth.test.ts index 274496ea20..f6d2fcfa25 100644 --- a/lambdas/functions/control-plane/src/github/auth.test.ts +++ b/lambdas/functions/control-plane/src/github/auth.test.ts @@ -6,7 +6,7 @@ import { getParameters } from '@aws-github-runner/aws-ssm-util'; import { generateKeyPairSync } from 'node:crypto'; import * as nock from 'nock'; -import { createGithubAppAuth, createOctokitClient } from './auth'; +import { createGithubAppAuth, createOctokitClient, selectRandomPat, createEnterprisePATClient } from './auth'; import { describe, it, expect, beforeEach, vi } from 'vitest'; type MockProxy = T & { @@ -297,3 +297,53 @@ describe('Test createGithubAppAuth', () => { expect(result.token).toBe(token); }); }); + +describe('Test selectRandomPat', () => { + it('returns the single PAT when only one is provided', () => { + const result = selectRandomPat('ghp_singletoken'); + expect(result).toBe('ghp_singletoken'); + }); + + it('returns one of the PATs from a comma-separated list', () => { + const pats = ['ghp_token1', 'ghp_token2', 'ghp_token3']; + const result = selectRandomPat(pats.join(',')); + expect(pats).toContain(result); + }); + + it('trims whitespace around PATs', () => { + const result = selectRandomPat(' ghp_token1 , ghp_token2 '); + expect(['ghp_token1', 'ghp_token2']).toContain(result); + }); + + it('ignores empty entries from extra commas', () => { + const result = selectRandomPat('ghp_token1,,ghp_token2,'); + expect(['ghp_token1', 'ghp_token2']).toContain(result); + }); + + it('throws an error for empty string', () => { + expect(() => selectRandomPat('')).toThrow('Enterprise PAT parameter value is empty.'); + }); + + it('throws an error for string with only commas and whitespace', () => { + expect(() => selectRandomPat(', , ,')).toThrow('Enterprise PAT parameter value is empty.'); + }); + + it('distributes selection across multiple PATs', () => { + const pats = 'ghp_a,ghp_b,ghp_c'; + const selections = new Set(); + // Run enough times to likely hit all PATs + for (let i = 0; i < 100; i++) { + selections.add(selectRandomPat(pats)); + } + expect(selections.size).toBeGreaterThan(1); + }); +}); + +describe('Test createEnterprisePATClient', () => { + it('throws when PARAMETER_ENTERPRISE_PAT_NAME is not set', async () => { + delete process.env.PARAMETER_ENTERPRISE_PAT_NAME; + await expect(createEnterprisePATClient()).rejects.toThrow( + 'PARAMETER_ENTERPRISE_PAT_NAME environment variable is not set.', + ); + }); +}); diff --git a/lambdas/functions/control-plane/src/github/auth.ts b/lambdas/functions/control-plane/src/github/auth.ts index 9a572c48a8..e7aca33a29 100644 --- a/lambdas/functions/control-plane/src/github/auth.ts +++ b/lambdas/functions/control-plane/src/github/auth.ts @@ -22,7 +22,7 @@ import { Octokit } from '@octokit/rest'; import { retry } from '@octokit/plugin-retry'; import { throttling } from '@octokit/plugin-throttling'; import { createChildLogger } from '@aws-github-runner/aws-powertools-util'; -import { getParameters } from '@aws-github-runner/aws-ssm-util'; +import { getParameter, getParameters } from '@aws-github-runner/aws-ssm-util'; import { EndpointDefaults } from '@octokit/types'; const logger = createChildLogger('gh-auth'); @@ -141,3 +141,42 @@ async function createAuth(installationId: number | undefined, ghesApiUrl: string } return createAppAuth(authOptions); } + +/** + * Select a random PAT from a comma-separated list of PATs. + * If only one PAT is provided, it is returned directly. + */ +export function selectRandomPat(patValue: string): string { + const pats = patValue + .split(',') + .map((p) => p.trim()) + .filter((p) => p.length > 0); + + if (pats.length === 0) { + throw new Error('Enterprise PAT parameter value is empty.'); + } + + const selected = pats[Math.floor(Math.random() * pats.length)]; + logger.debug(`Selected enterprise PAT (1 of ${pats.length})`); + return selected; +} + +/** + * Create an Octokit client authenticated with an enterprise PAT. + * Reads the PAT from SSM Parameter Store using PARAMETER_ENTERPRISE_PAT_NAME env var. + * The SSM parameter value may contain multiple comma-separated PATs; one is selected at random + * to distribute API calls and avoid rate limiting. + * Used for all enterprise-level GitHub API calls. + */ +export async function createEnterprisePATClient(ghesApiUrl = ''): Promise { + const patParameterName = process.env.PARAMETER_ENTERPRISE_PAT_NAME; + if (!patParameterName) { + throw new Error( + 'PARAMETER_ENTERPRISE_PAT_NAME environment variable is not set. ' + + 'Enterprise runner registration requires a PAT stored in SSM Parameter Store.', + ); + } + const patValue = await getParameter(patParameterName); + const pat = selectRandomPat(patValue); + return createOctokitClient(pat, ghesApiUrl); +} diff --git a/lambdas/functions/control-plane/src/github/rate-limit.ts b/lambdas/functions/control-plane/src/github/rate-limit.ts index 8ebb8e3f84..46e2e71571 100644 --- a/lambdas/functions/control-plane/src/github/rate-limit.ts +++ b/lambdas/functions/control-plane/src/github/rate-limit.ts @@ -8,8 +8,13 @@ import { getParameter } from '@aws-github-runner/aws-ssm-util'; let appIdPromise: Promise | null = null; async function getAppId(): Promise { + const paramName = process.env.PARAMETER_GITHUB_APP_ID_NAME; + if (!paramName) { + // Enterprise runners don't have a GitHub App ID — use a descriptive label + return 'enterprise-pat'; + } if (!appIdPromise) { - appIdPromise = getParameter(process.env.PARAMETER_GITHUB_APP_ID_NAME); + appIdPromise = getParameter(paramName); } return appIdPromise; } diff --git a/lambdas/functions/control-plane/src/pool/pool.test.ts b/lambdas/functions/control-plane/src/pool/pool.test.ts index ee4e36a463..0c9285ea72 100644 --- a/lambdas/functions/control-plane/src/pool/pool.test.ts +++ b/lambdas/functions/control-plane/src/pool/pool.test.ts @@ -132,7 +132,7 @@ beforeEach(() => { process.env.GITHUB_APP_CLIENT_SECRET = 'TEST_CLIENT_SECRET'; process.env.RUNNERS_MAXIMUM_COUNT = '3'; process.env.ENVIRONMENT = 'unit-test-environment'; - process.env.ENABLE_ORGANIZATION_RUNNERS = 'true'; + process.env.RUNNER_REGISTRATION_LEVEL = 'org'; process.env.LAUNCH_TEMPLATE_NAME = 'lt-1'; process.env.SUBNET_IDS = 'subnet-123'; process.env.SSM_TOKEN_PATH = '/github-action-runners/default/runners/tokens'; diff --git a/lambdas/functions/control-plane/src/pool/pool.ts b/lambdas/functions/control-plane/src/pool/pool.ts index cece8d9951..0a2cf4c556 100644 --- a/lambdas/functions/control-plane/src/pool/pool.ts +++ b/lambdas/functions/control-plane/src/pool/pool.ts @@ -3,10 +3,16 @@ import { createChildLogger } from '@aws-github-runner/aws-powertools-util'; import yn from 'yn'; import { bootTimeExceeded, listEC2Runners } from '../aws/runners'; -import { RunnerList } from '../aws/runners.d'; -import { createGithubAppAuth, createGithubInstallationAuth, createOctokitClient } from '../github/auth'; +import { RunnerList, RunnerType } from '../aws/runners.d'; +import { + createGithubAppAuth, + createGithubInstallationAuth, + createOctokitClient, + createEnterprisePATClient, +} from '../github/auth'; import { createRunners, getGitHubEnterpriseApiUrl } from '../scale-runners/scale-up'; import { validateSsmParameterStoreTags } from '../scale-runners/scale-up'; +import { resolveRunnerType } from '../scale-runners/runner-config'; const logger = createChildLogger('pool'); @@ -50,22 +56,31 @@ export async function adjust(event: PoolEvent): Promise { const { ghesApiUrl, ghesBaseUrl } = getGitHubEnterpriseApiUrl(); - const installationId = await getInstallationId(ghesApiUrl, runnerOwner); - const ghAuth = await createGithubInstallationAuth(installationId, ghesApiUrl); - const githubInstallationClient = await createOctokitClient(ghAuth.token, ghesApiUrl); + const runnerType = resolveRunnerType(); + const enterpriseSlug = process.env.ENTERPRISE_SLUG; + + let githubInstallationClient: Octokit; + if (runnerType === 'Enterprise') { + githubInstallationClient = await createEnterprisePATClient(ghesApiUrl); + } else { + const installationId = await getInstallationId(ghesApiUrl, runnerOwner); + const ghAuth = await createGithubInstallationAuth(installationId, ghesApiUrl); + githubInstallationClient = await createOctokitClient(ghAuth.token, ghesApiUrl); + } // Get statuses of runners registered in GitHub const runnerStatusses = await getGitHubRegisteredRunnnerStatusses( githubInstallationClient, - runnerOwner, + runnerType === 'Enterprise' ? enterpriseSlug! : runnerOwner, runnerNamePrefix, + runnerType, ); // Look up the managed ec2 runners in AWS, but running does not mean idle const ec2runners = await listEC2Runners({ environment, - runnerOwner, - runnerType: 'Org', + runnerOwner: runnerType === 'Enterprise' ? enterpriseSlug! : runnerOwner, + runnerType, statuses: ['running'], }); @@ -81,9 +96,10 @@ export async function adjust(event: PoolEvent): Promise { ghesBaseUrl, runnerLabels, runnerGroup, - runnerOwner, + runnerOwner: runnerType === 'Enterprise' ? enterpriseSlug! : runnerOwner, runnerNamePrefix, - runnerType: 'Org', + runnerType, + enterpriseSlug, disableAutoUpdate: disableAutoUpdate, ssmTokenPath, ssmConfigPath, @@ -152,11 +168,18 @@ async function getGitHubRegisteredRunnnerStatusses( ghClient: Octokit, runnerOwner: string, runnerNamePrefix: string, + runnerType: RunnerType, ): Promise> { - const runners = await ghClient.paginate(ghClient.actions.listSelfHostedRunnersForOrg, { - org: runnerOwner, - per_page: 100, - }); + const runners = + runnerType === 'Enterprise' + ? ((await ghClient.paginate('GET /enterprises/{enterprise}/actions/runners', { + enterprise: runnerOwner, + per_page: 100, + })) as { name: string; busy: boolean; status: string }[]) + : await ghClient.paginate(ghClient.actions.listSelfHostedRunnersForOrg, { + org: runnerOwner, + per_page: 100, + }); const runnerStatus = new Map(); for (const runner of runners) { runner.name = runnerNamePrefix ? runner.name.replace(runnerNamePrefix, '') : runner.name; diff --git a/lambdas/functions/control-plane/src/scale-runners/job-retry.test.ts b/lambdas/functions/control-plane/src/scale-runners/job-retry.test.ts index f807d06d8a..0e07b33e7d 100644 --- a/lambdas/functions/control-plane/src/scale-runners/job-retry.test.ts +++ b/lambdas/functions/control-plane/src/scale-runners/job-retry.test.ts @@ -156,7 +156,7 @@ describe(`Test job retry check`, () => { repoOwnerType: 'Organization', retryCounter: 0, }; - process.env.ENABLE_ORGANIZATION_RUNNERS = 'true'; + process.env.RUNNER_REGISTRATION_LEVEL = 'org'; process.env.RUNNER_NAME_PREFIX = 'test'; process.env.JOB_QUEUE_SCALE_UP_URL = 'https://sqs.eu-west-1.amazonaws.com/123456789/webhook_events_workflow_job_queue'; @@ -192,7 +192,7 @@ describe(`Test job retry check`, () => { retryCounter: 1, }; - process.env.ENABLE_ORGANIZATION_RUNNERS = 'true'; + process.env.RUNNER_REGISTRATION_LEVEL = 'org'; process.env.ENVIRONMENT = 'test'; process.env.RUNNER_NAME_PREFIX = 'test'; process.env.ENABLE_METRIC_JOB_RETRY = 'true'; @@ -233,7 +233,7 @@ describe(`Test job retry check`, () => { repoOwnerType: 'Organization', retryCounter: 0, }; - process.env.ENABLE_ORGANIZATION_RUNNERS = 'true'; + process.env.RUNNER_REGISTRATION_LEVEL = 'org'; process.env.RUNNER_NAME_PREFIX = 'test'; process.env.JOB_QUEUE_SCALE_UP_URL = 'https://sqs.eu-west-1.amazonaws.com/123456789/webhook_events_workflow_job_queue'; @@ -262,7 +262,7 @@ describe(`Test job retry check`, () => { repoOwnerType: 'Organization', retryCounter: 0, }; - process.env.ENABLE_ORGANIZATION_RUNNERS = 'false'; + process.env.RUNNER_REGISTRATION_LEVEL = 'repo'; // act await checkAndRetryJob(message); @@ -319,7 +319,7 @@ describe('Test job retry handler (batch processing)', () => { beforeEach(() => { vi.clearAllMocks(); - process.env.ENABLE_ORGANIZATION_RUNNERS = 'true'; + process.env.RUNNER_REGISTRATION_LEVEL = 'org'; process.env.JOB_QUEUE_SCALE_UP_URL = 'https://sqs.example.com/queue'; }); diff --git a/lambdas/functions/control-plane/src/scale-runners/job-retry.ts b/lambdas/functions/control-plane/src/scale-runners/job-retry.ts index bd9ebbd3b9..adcf6da812 100644 --- a/lambdas/functions/control-plane/src/scale-runners/job-retry.ts +++ b/lambdas/functions/control-plane/src/scale-runners/job-retry.ts @@ -2,8 +2,10 @@ import { addPersistentContextToChildLogger, createSingleMetric, logger } from '@ import { publishMessage } from '../aws/sqs'; import { ActionRequestMessage, ActionRequestMessageRetry, isJobQueued, getGitHubEnterpriseApiUrl } from './scale-up'; import { getOctokit } from '../github/octokit'; +import { createEnterprisePATClient } from '../github/auth'; import { MetricUnit } from '@aws-lambda-powertools/metrics'; import yn from 'yn'; +import { resolveRunnerType } from './runner-config'; interface JobRetryConfig { enable: boolean; @@ -37,9 +39,14 @@ export async function publishRetryMessage(payload: ActionRequestMessage): Promis } export async function checkAndRetryJob(payload: ActionRequestMessageRetry): Promise { - const enableOrgLevel = yn(process.env.ENABLE_ORGANIZATION_RUNNERS, { default: true }); - const runnerType = enableOrgLevel ? 'Org' : 'Repo'; - const runnerOwner = enableOrgLevel ? payload.repositoryOwner : `${payload.repositoryOwner}/${payload.repositoryName}`; + const runnerType = resolveRunnerType(); + const enableOrgLevel = runnerType !== 'Repo'; + const runnerOwner = + runnerType === 'Enterprise' + ? (process.env.ENTERPRISE_SLUG ?? '') + : runnerType === 'Org' + ? payload.repositoryOwner + : `${payload.repositoryOwner}/${payload.repositoryName}`; const runnerNamePrefix = process.env.RUNNER_NAME_PREFIX ?? ''; const jobQueueUrl = process.env.JOB_QUEUE_SCALE_UP_URL ?? ''; const enableMetrics = yn(process.env.ENABLE_METRIC_JOB_RETRY, { default: false }); @@ -60,7 +67,10 @@ export async function checkAndRetryJob(payload: ActionRequestMessageRetry): Prom logger.info(`Received event`); const { ghesApiUrl } = getGitHubEnterpriseApiUrl(); - const ghClient = await getOctokit(ghesApiUrl, enableOrgLevel, payload); + const ghClient = + runnerType === 'Enterprise' + ? await createEnterprisePATClient(ghesApiUrl) + : await getOctokit(ghesApiUrl, enableOrgLevel, payload); // check job is still queued if (await isJobQueued(ghClient, payload)) { diff --git a/lambdas/functions/control-plane/src/scale-runners/runner-config.test.ts b/lambdas/functions/control-plane/src/scale-runners/runner-config.test.ts new file mode 100644 index 0000000000..be0f3e5b59 --- /dev/null +++ b/lambdas/functions/control-plane/src/scale-runners/runner-config.test.ts @@ -0,0 +1,44 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { resolveRunnerType } from './runner-config'; + +describe('resolveRunnerType', () => { + const originalEnv = process.env; + + beforeEach(() => { + process.env = { ...originalEnv }; + delete process.env.RUNNER_REGISTRATION_LEVEL; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + it('should return Enterprise when RUNNER_REGISTRATION_LEVEL is enterprise', () => { + process.env.RUNNER_REGISTRATION_LEVEL = 'enterprise'; + expect(resolveRunnerType()).toBe('Enterprise'); + }); + + it('should return Org when RUNNER_REGISTRATION_LEVEL is org', () => { + process.env.RUNNER_REGISTRATION_LEVEL = 'org'; + expect(resolveRunnerType()).toBe('Org'); + }); + + it('should return Repo when RUNNER_REGISTRATION_LEVEL is repo', () => { + process.env.RUNNER_REGISTRATION_LEVEL = 'repo'; + expect(resolveRunnerType()).toBe('Repo'); + }); + + it('should throw for invalid RUNNER_REGISTRATION_LEVEL', () => { + process.env.RUNNER_REGISTRATION_LEVEL = 'invalid'; + expect(() => resolveRunnerType()).toThrow('Invalid RUNNER_REGISTRATION_LEVEL'); + }); + + it('should default to Repo when RUNNER_REGISTRATION_LEVEL is not set', () => { + expect(resolveRunnerType()).toBe('Repo'); + }); + + it('should default to Repo when RUNNER_REGISTRATION_LEVEL is empty', () => { + process.env.RUNNER_REGISTRATION_LEVEL = ''; + expect(resolveRunnerType()).toBe('Repo'); + }); +}); diff --git a/lambdas/functions/control-plane/src/scale-runners/runner-config.ts b/lambdas/functions/control-plane/src/scale-runners/runner-config.ts new file mode 100644 index 0000000000..a3fc936711 --- /dev/null +++ b/lambdas/functions/control-plane/src/scale-runners/runner-config.ts @@ -0,0 +1,26 @@ +import { RunnerType } from '../aws/runners.d'; + +/** + * Resolves the RunnerType from the RUNNER_REGISTRATION_LEVEL environment variable. + * + * Valid values: 'enterprise' | 'org' | 'repo'. + * Defaults to 'Repo' when not set. + */ +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'; +} diff --git a/lambdas/functions/control-plane/src/scale-runners/scale-down.test.ts b/lambdas/functions/control-plane/src/scale-runners/scale-down.test.ts index 2dfb190a38..88bce33681 100644 --- a/lambdas/functions/control-plane/src/scale-runners/scale-down.test.ts +++ b/lambdas/functions/control-plane/src/scale-runners/scale-down.test.ts @@ -24,6 +24,7 @@ const mockOctokit = { getSelfHostedRunnerForRepo: vi.fn(), }, paginate: vi.fn(), + request: vi.fn(), }; vi.mock('@octokit/rest', () => ({ Octokit: vi.fn().mockImplementation(function () { @@ -45,6 +46,7 @@ vi.mock('./../github/auth', async () => ({ createGithubAppAuth: vi.fn(), createGithubInstallationAuth: vi.fn(), createOctokitClient: vi.fn(), + createEnterprisePATClient: vi.fn(), })); vi.mock('./cache', async () => ({ @@ -773,6 +775,127 @@ describe('Scale down runners', () => { expect(runnersTest[2].launchTime).not.toBeDefined(); }); }); + + describe('scale-down enterprise runner support', () => { + const mockedEnterprisePATClient = vi.mocked(ghAuth.createEnterprisePATClient); + + beforeEach(() => { + process.env.PARAMETER_ENTERPRISE_PAT_NAME = '/ssm/enterprise-pat'; + mockedEnterprisePATClient.mockResolvedValue(mockOctokit as unknown as Octokit); + + // Enterprise endpoints use client.request() instead of client.actions.* + mockOctokit.request = vi.fn(); + mockOctokit.paginate = vi.fn(); + }); + + it('should use PAT client for enterprise runners and terminate idle runner', async () => { + const launchTime = moment(new Date()) + .subtract(MINIMUM_TIME_RUNNING_IN_MINUTES + 5, 'minutes') + .toDate(); + const enterpriseRunner: RunnerList = { + instanceId: 'i-enterprise-idle', + launchTime, + type: 'Enterprise', + owner: 'my-enterprise', + registered: true, + orphan: false, + }; + + mockListRunners.mockImplementation(async (filter) => { + if (filter?.orphan) return []; + return [enterpriseRunner]; + }); + + // Mock paginate for listGitHubRunners - returns the runner as registered + mockOctokit.paginate.mockResolvedValue([{ id: 12345, name: 'i-enterprise-idle' }]); + + // Mock request for getGitHubRunnerBusyState (GET runner) - runner is not busy + mockOctokit.request.mockImplementation(async (route: string) => { + if (route.startsWith('GET')) { + return { data: { busy: false, status: 'online' }, headers: {} }; + } + if (route.startsWith('DELETE')) { + return { status: 204, headers: {} }; + } + return { headers: {} }; + }); + + await scaleDown(); + + expect(mockedEnterprisePATClient).toHaveBeenCalled(); + expect(mockOctokit.paginate).toHaveBeenCalledWith( + 'GET /enterprises/{enterprise}/actions/runners', + expect.objectContaining({ enterprise: 'my-enterprise' }), + ); + expect(mockOctokit.request).toHaveBeenCalledWith( + 'GET /enterprises/{enterprise}/actions/runners/{runner_id}', + expect.objectContaining({ enterprise: 'my-enterprise', runner_id: 12345 }), + ); + expect(mockOctokit.request).toHaveBeenCalledWith( + 'DELETE /enterprises/{enterprise}/actions/runners/{runner_id}', + expect.objectContaining({ enterprise: 'my-enterprise', runner_id: 12345 }), + ); + expect(terminateRunner).toHaveBeenCalledWith('i-enterprise-idle'); + }); + + it('should not terminate busy enterprise runner', async () => { + const launchTime = moment(new Date()) + .subtract(MINIMUM_TIME_RUNNING_IN_MINUTES + 5, 'minutes') + .toDate(); + const enterpriseRunner: RunnerList = { + instanceId: 'i-enterprise-busy', + launchTime, + type: 'Enterprise', + owner: 'my-enterprise', + registered: true, + orphan: false, + }; + + mockListRunners.mockImplementation(async (filter) => { + if (filter?.orphan) return []; + return [enterpriseRunner]; + }); + + mockOctokit.paginate.mockResolvedValue([{ id: 12345, name: 'i-enterprise-busy' }]); + + // Runner is busy + mockOctokit.request.mockResolvedValue({ + data: { busy: true, status: 'online' }, + headers: {}, + }); + + await scaleDown(); + + expect(mockedEnterprisePATClient).toHaveBeenCalled(); + expect(terminateRunner).not.toHaveBeenCalled(); + }); + + it('should not use GitHub App auth for enterprise runners', async () => { + const launchTime = moment(new Date()) + .subtract(MINIMUM_TIME_RUNNING_IN_MINUTES + 5, 'minutes') + .toDate(); + const enterpriseRunner: RunnerList = { + instanceId: 'i-enterprise-noapp', + launchTime, + type: 'Enterprise', + owner: 'my-enterprise', + registered: true, + orphan: false, + }; + + mockListRunners.mockImplementation(async (filter) => { + if (filter?.orphan) return []; + return [enterpriseRunner]; + }); + mockOctokit.paginate.mockResolvedValue([]); + + await scaleDown(); + + expect(mockedEnterprisePATClient).toHaveBeenCalled(); + expect(mockedAppAuth).not.toHaveBeenCalled(); + expect(mockedInstallationAuth).not.toHaveBeenCalled(); + }); + }); }); function mockAwsRunners(runners: RunnerTestItem[]) { diff --git a/lambdas/functions/control-plane/src/scale-runners/scale-down.ts b/lambdas/functions/control-plane/src/scale-runners/scale-down.ts index 6086af7714..bb4d42f34a 100644 --- a/lambdas/functions/control-plane/src/scale-runners/scale-down.ts +++ b/lambdas/functions/control-plane/src/scale-runners/scale-down.ts @@ -4,7 +4,12 @@ import { RequestError } from '@octokit/request-error'; import { createChildLogger } from '@aws-github-runner/aws-powertools-util'; import moment from 'moment'; -import { createGithubAppAuth, createGithubInstallationAuth, createOctokitClient } from '../github/auth'; +import { + createGithubAppAuth, + createGithubInstallationAuth, + createOctokitClient, + createEnterprisePATClient, +} from '../github/auth'; import { bootTimeExceeded, listEC2Runners, tag, untag, terminateRunner } from './../aws/runners'; import { RunnerInfo, RunnerList } from './../aws/runners.d'; import { GhRunners, githubCache } from './cache'; @@ -29,6 +34,15 @@ async function getOrCreateOctokit(runner: RunnerInfo): Promise { logger.debug(`[createGitHubClientForRunner] Cache miss for ${key}`); const { ghesApiUrl } = getGitHubEnterpriseApiUrl(); + + // Enterprise runners use PAT authentication + if (runner.type === 'Enterprise') { + const octokit = await createEnterprisePATClient(ghesApiUrl); + githubCache.clients.set(key, octokit); + return octokit; + } + + // Org/Repo runners use GitHub App installation authentication const ghAuthPre = await createGithubAppAuth(undefined, ghesApiUrl); const githubClientPre = await createOctokitClient(ghAuthPre.token, ghesApiUrl); @@ -59,19 +73,24 @@ async function getGitHubSelfHostedRunnerState( ): Promise { try { const state = - ec2runner.type === 'Org' - ? await client.actions.getSelfHostedRunnerForOrg({ + ec2runner.type === 'Enterprise' + ? await client.request('GET /enterprises/{enterprise}/actions/runners/{runner_id}', { runner_id: runnerId, - org: ec2runner.owner, + enterprise: ec2runner.owner, }) - : await client.actions.getSelfHostedRunnerForRepo({ - runner_id: runnerId, - owner: ec2runner.owner.split('/')[0], - repo: ec2runner.owner.split('/')[1], - }); + : ec2runner.type === 'Org' + ? await client.actions.getSelfHostedRunnerForOrg({ + runner_id: runnerId, + org: ec2runner.owner, + }) + : await client.actions.getSelfHostedRunnerForRepo({ + runner_id: runnerId, + owner: ec2runner.owner.split('/')[0], + repo: ec2runner.owner.split('/')[1], + }); metricGitHubAppRateLimit(state.headers); - return state.data; + return state.data as RunnerState; } catch (error) { if (error instanceof RequestError && error.status === 404) { logger.info(`Runner '${ec2runner.instanceId}' with GitHub Runner ID '${runnerId}' not found on GitHub (404)`); @@ -104,16 +123,21 @@ async function listGitHubRunners(runner: RunnerInfo): Promise { logger.debug(`[listGithubRunners] Cache miss for ${key}`); const client = await getOrCreateOctokit(runner); const runners = - runner.type === 'Org' - ? await client.paginate(client.actions.listSelfHostedRunnersForOrg, { - org: runner.owner, + runner.type === 'Enterprise' + ? ((await client.paginate('GET /enterprises/{enterprise}/actions/runners', { + enterprise: runner.owner, per_page: 100, - }) - : await client.paginate(client.actions.listSelfHostedRunnersForRepo, { - owner: runner.owner.split('/')[0], - repo: runner.owner.split('/')[1], - per_page: 100, - }); + })) as GhRunners) + : runner.type === 'Org' + ? await client.paginate(client.actions.listSelfHostedRunnersForOrg, { + org: runner.owner, + per_page: 100, + }) + : await client.paginate(client.actions.listSelfHostedRunnersForRepo, { + owner: runner.owner.split('/')[0], + repo: runner.owner.split('/')[1], + per_page: 100, + }); githubCache.runners.set(key, runners); logger.debug(`[listGithubRunners] Cache set for ${key}`); logger.debug(`[listGithubRunners] Runners: ${JSON.stringify(runners)}`); @@ -149,21 +173,26 @@ async function removeRunner(ec2runner: RunnerInfo, ghRunnerIds: number[]): Promi const statuses = await Promise.all( ghRunnerIds.map(async (ghRunnerId) => { return ( - ec2runner.type === 'Org' - ? await githubAppClient.actions.deleteSelfHostedRunnerFromOrg({ - runner_id: ghRunnerId, - org: ec2runner.owner, - }) - : await githubAppClient.actions.deleteSelfHostedRunnerFromRepo({ + ec2runner.type === 'Enterprise' + ? await githubAppClient.request('DELETE /enterprises/{enterprise}/actions/runners/{runner_id}', { runner_id: ghRunnerId, - owner: ec2runner.owner.split('/')[0], - repo: ec2runner.owner.split('/')[1], + enterprise: ec2runner.owner, }) + : ec2runner.type === 'Org' + ? await githubAppClient.actions.deleteSelfHostedRunnerFromOrg({ + runner_id: ghRunnerId, + org: ec2runner.owner, + }) + : await githubAppClient.actions.deleteSelfHostedRunnerFromRepo({ + runner_id: ghRunnerId, + owner: ec2runner.owner.split('/')[0], + repo: ec2runner.owner.split('/')[1], + }) ).status; }), ); - if (statuses.every((status) => status == 204)) { + if (statuses.every((status: number) => status == 204)) { await terminateRunner(ec2runner.instanceId); logger.info(`AWS runner instance '${ec2runner.instanceId}' is terminated and GitHub runner is de-registered.`); } else { diff --git a/lambdas/functions/control-plane/src/scale-runners/scale-up.test.ts b/lambdas/functions/control-plane/src/scale-runners/scale-up.test.ts index 8ac2c14489..f96a1c204d 100644 --- a/lambdas/functions/control-plane/src/scale-runners/scale-up.test.ts +++ b/lambdas/functions/control-plane/src/scale-runners/scale-up.test.ts @@ -16,6 +16,7 @@ import type { Octokit } from '@octokit/rest'; const mockOctokit = { paginate: vi.fn(), + request: vi.fn(), checks: { get: vi.fn() }, actions: { createRegistrationTokenForOrg: vi.fn(), @@ -52,6 +53,7 @@ vi.mock('./../github/auth', async () => ({ createGithubAppAuth: vi.fn(), createGithubInstallationAuth: vi.fn(), createOctokitClient: vi.fn(), + createEnterprisePATClient: vi.fn(), })); vi.mock('@aws-github-runner/aws-ssm-util', async () => { @@ -200,7 +202,7 @@ describe('scaleUp with GHES', () => { describe('on org level', () => { beforeEach(() => { - process.env.ENABLE_ORGANIZATION_RUNNERS = 'true'; + process.env.RUNNER_REGISTRATION_LEVEL = 'org'; process.env.ENABLE_EPHEMERAL_RUNNERS = 'true'; process.env.RUNNER_NAME_PREFIX = 'unit-test-'; process.env.RUNNER_GROUP_NAME = 'Default'; @@ -274,7 +276,7 @@ describe('scaleUp with GHES', () => { }); it('Discards event if it is a User repo and org level runners is enabled', async () => { - process.env.ENABLE_ORGANIZATION_RUNNERS = 'true'; + process.env.RUNNER_REGISTRATION_LEVEL = 'org'; const USER_REPO_TEST_DATA = structuredClone(TEST_DATA); USER_REPO_TEST_DATA[0].repoOwnerType = 'User'; await scaleUpModule.scaleUp(USER_REPO_TEST_DATA); @@ -574,7 +576,7 @@ describe('scaleUp with GHES', () => { }); describe('on repo level', () => { beforeEach(() => { - process.env.ENABLE_ORGANIZATION_RUNNERS = 'false'; + process.env.RUNNER_REGISTRATION_LEVEL = 'repo'; process.env.RUNNER_NAME_PREFIX = 'unit-test'; expectedRunnerParams = { ...EXPECTED_RUNNER_PARAMS }; expectedRunnerParams.runnerType = 'Repo'; @@ -642,7 +644,7 @@ describe('scaleUp with GHES', () => { describe('Batch processing', () => { beforeEach(() => { - process.env.ENABLE_ORGANIZATION_RUNNERS = 'true'; + process.env.RUNNER_REGISTRATION_LEVEL = 'org'; process.env.ENABLE_EPHEMERAL_RUNNERS = 'true'; process.env.RUNNERS_MAXIMUM_COUNT = '10'; }); @@ -697,7 +699,7 @@ describe('scaleUp with GHES', () => { }); it('Should handle multiple messages for different repositories when org-level is disabled', async () => { - process.env.ENABLE_ORGANIZATION_RUNNERS = 'false'; + process.env.RUNNER_REGISTRATION_LEVEL = 'repo'; const messages = createTestMessages(3, [ { repositoryOwner: 'owner1', repositoryName: 'repo1' }, { repositoryOwner: 'owner1', repositoryName: 'repo2' }, @@ -892,7 +894,7 @@ describe('scaleUp with public GH', () => { describe('on org level', () => { beforeEach(() => { - process.env.ENABLE_ORGANIZATION_RUNNERS = 'true'; + process.env.RUNNER_REGISTRATION_LEVEL = 'org'; process.env.RUNNER_NAME_PREFIX = 'unit-test'; expectedRunnerParams = { ...EXPECTED_RUNNER_PARAMS }; }); @@ -938,7 +940,7 @@ describe('scaleUp with public GH', () => { beforeEach(() => { mockSSMClient.reset(); - process.env.ENABLE_ORGANIZATION_RUNNERS = 'false'; + process.env.RUNNER_REGISTRATION_LEVEL = 'repo'; process.env.RUNNER_NAME_PREFIX = 'unit-test'; expectedRunnerParams = { ...EXPECTED_RUNNER_PARAMS }; expectedRunnerParams.runnerType = 'Repo'; @@ -1115,7 +1117,7 @@ describe('scaleUp with public GH', () => { beforeEach(() => { setDefaults(); - process.env.ENABLE_ORGANIZATION_RUNNERS = 'true'; + process.env.RUNNER_REGISTRATION_LEVEL = 'org'; process.env.ENABLE_EPHEMERAL_RUNNERS = 'true'; process.env.RUNNERS_MAXIMUM_COUNT = '10'; }); @@ -1158,7 +1160,7 @@ describe('scaleUp with public GH', () => { }); it('Should handle multiple messages for different repositories when org-level is disabled', async () => { - process.env.ENABLE_ORGANIZATION_RUNNERS = 'false'; + process.env.RUNNER_REGISTRATION_LEVEL = 'repo'; const messages = createTestMessages(3, [ { repositoryOwner: 'owner1', repositoryName: 'repo1' }, { repositoryOwner: 'owner1', repositoryName: 'repo2' }, @@ -1351,7 +1353,7 @@ describe('scaleUp with Github Data Residency', () => { describe('on org level', () => { beforeEach(() => { - process.env.ENABLE_ORGANIZATION_RUNNERS = 'true'; + process.env.RUNNER_REGISTRATION_LEVEL = 'org'; process.env.ENABLE_EPHEMERAL_RUNNERS = 'true'; process.env.RUNNER_NAME_PREFIX = 'unit-test-'; process.env.RUNNER_GROUP_NAME = 'Default'; @@ -1425,7 +1427,7 @@ describe('scaleUp with Github Data Residency', () => { }); it('Discards event if it is a User repo and org level runners is enabled', async () => { - process.env.ENABLE_ORGANIZATION_RUNNERS = 'true'; + process.env.RUNNER_REGISTRATION_LEVEL = 'org'; const USER_REPO_TEST_DATA = structuredClone(TEST_DATA); USER_REPO_TEST_DATA[0].repoOwnerType = 'User'; await scaleUpModule.scaleUp(USER_REPO_TEST_DATA); @@ -1559,7 +1561,7 @@ describe('scaleUp with Github Data Residency', () => { }); describe('on repo level', () => { beforeEach(() => { - process.env.ENABLE_ORGANIZATION_RUNNERS = 'false'; + process.env.RUNNER_REGISTRATION_LEVEL = 'repo'; process.env.RUNNER_NAME_PREFIX = 'unit-test'; expectedRunnerParams = { ...EXPECTED_RUNNER_PARAMS }; expectedRunnerParams.runnerType = 'Repo'; @@ -1640,7 +1642,7 @@ describe('scaleUp with Github Data Residency', () => { beforeEach(() => { setDefaults(); - process.env.ENABLE_ORGANIZATION_RUNNERS = 'true'; + process.env.RUNNER_REGISTRATION_LEVEL = 'org'; process.env.ENABLE_EPHEMERAL_RUNNERS = 'true'; process.env.RUNNERS_MAXIMUM_COUNT = '10'; }); @@ -1683,7 +1685,7 @@ describe('scaleUp with Github Data Residency', () => { }); it('Should handle multiple messages for different repositories when org-level is disabled', async () => { - process.env.ENABLE_ORGANIZATION_RUNNERS = 'false'; + process.env.RUNNER_REGISTRATION_LEVEL = 'repo'; const messages = createTestMessages(3, [ { repositoryOwner: 'owner1', repositoryName: 'repo1' }, { repositoryOwner: 'owner1', repositoryName: 'repo2' }, @@ -1856,7 +1858,7 @@ describe('scaleUp with Github Data Residency', () => { describe('Retry mechanism tests', () => { beforeEach(() => { - process.env.ENABLE_ORGANIZATION_RUNNERS = 'true'; + process.env.RUNNER_REGISTRATION_LEVEL = 'org'; process.env.ENABLE_EPHEMERAL_RUNNERS = 'true'; process.env.ENABLE_JOB_QUEUED_CHECK = 'true'; process.env.RUNNERS_MAXIMUM_COUNT = '10'; @@ -2067,6 +2069,22 @@ function defaultOctokitMockImpl() { mockOctokit.actions.createRegistrationTokenForOrg.mockImplementation(() => mockTokenReturnValue); mockOctokit.actions.createRegistrationTokenForRepo.mockImplementation(() => mockTokenReturnValue); + // Enterprise API calls use ghClient.request() instead of typed action methods + mockOctokit.request.mockImplementation((route: string) => { + if (route === 'POST /enterprises/{enterprise}/actions/runners/registration-token') { + return mockTokenReturnValue; + } + if (route === 'POST /enterprises/{enterprise}/actions/runners/generate-jitconfig') { + return { + data: { + runner: { id: 9876543210 }, + encoded_jit_config: 'TEST_JIT_CONFIG_ENTERPRISE', + }, + headers: {}, + }; + } + throw new Error(`Unexpected request route: ${route}`); + }); mockOctokit.apps.getOrgInstallation.mockImplementation(() => mockInstallationIdReturnValueOrgs); mockOctokit.apps.getRepoInstallation.mockImplementation(() => mockInstallationIdReturnValueRepos); } @@ -2082,3 +2100,77 @@ function defaultSSMGetParameterMockImpl() { } }); } + +describe('scaleUp with enterprise runner registration level', () => { + const mockedEnterprisePATClient = vi.mocked(ghAuth.createEnterprisePATClient); + + beforeEach(() => { + process.env.RUNNER_REGISTRATION_LEVEL = 'enterprise'; + process.env.ENTERPRISE_SLUG = 'my-enterprise'; + process.env.PARAMETER_ENTERPRISE_PAT_NAME = '/ssm/enterprise-pat'; + mockedEnterprisePATClient.mockResolvedValue(mockOctokit as unknown as Octokit); + }); + + it('should use PAT client for enterprise runners', async () => { + await scaleUpModule.scaleUp(TEST_DATA); + expect(mockedEnterprisePATClient).toHaveBeenCalled(); + }); + + it('should not use GitHub App auth for enterprise runners', async () => { + await scaleUpModule.scaleUp(TEST_DATA); + expect(mockedAppAuth).not.toHaveBeenCalled(); + expect(mockedInstallationAuth).not.toHaveBeenCalled(); + }); + + it('should call createRegistrationTokenForEnterprise', async () => { + await scaleUpModule.scaleUp(TEST_DATA); + expect(mockOctokit.request).toHaveBeenCalledWith( + 'POST /enterprises/{enterprise}/actions/runners/registration-token', + { + enterprise: 'my-enterprise', + }, + ); + }); + + it('should pass enterprise runner type to createRunner', async () => { + await scaleUpModule.scaleUp(TEST_DATA); + expect(mockCreateRunner).toHaveBeenCalledWith( + expect.objectContaining({ + runnerType: 'Enterprise', + runnerOwner: 'my-enterprise', + enterpriseSlug: 'my-enterprise', + }), + ); + }); + + it('should accept non-Organization owner types for enterprise runners', () => { + const payload: scaleUpModule.ActionRequestMessage = { + ...TEST_DATA_SINGLE, + repoOwnerType: 'User', + }; + expect(scaleUpModule.isValidRepoOwnerType(payload, 'Enterprise')).toBe(true); + }); + + it('should reject non-Organization owner types for org runners', () => { + const payload: scaleUpModule.ActionRequestMessage = { + ...TEST_DATA_SINGLE, + repoOwnerType: 'User', + }; + expect(scaleUpModule.isValidRepoOwnerType(payload, 'Org')).toBe(false); + }); + + it('should use RUNNER_REGISTRATION_LEVEL to determine runner type', async () => { + process.env.RUNNER_REGISTRATION_LEVEL = 'repo'; + await scaleUpModule.scaleUp(TEST_DATA); + expect(mockOctokit.actions.createRegistrationTokenForRepo).toHaveBeenCalled(); + }); +}); + +describe('scaleUp defaults', () => { + it('should default to repo level when RUNNER_REGISTRATION_LEVEL is not set', async () => { + delete process.env.RUNNER_REGISTRATION_LEVEL; + const repoPayload = [{ ...TEST_DATA_SINGLE, repoOwnerType: 'User', messageId: 'msg1' }]; + await scaleUpModule.scaleUp(repoPayload); + expect(mockOctokit.actions.createRegistrationTokenForRepo).toHaveBeenCalled(); + }); +}); diff --git a/lambdas/functions/control-plane/src/scale-runners/scale-up.ts b/lambdas/functions/control-plane/src/scale-runners/scale-up.ts index 395c87e8f8..c17565aefc 100644 --- a/lambdas/functions/control-plane/src/scale-runners/scale-up.ts +++ b/lambdas/functions/control-plane/src/scale-runners/scale-up.ts @@ -3,11 +3,17 @@ import { addPersistentContextToChildLogger, createChildLogger } from '@aws-githu import { getParameter, putParameter } from '@aws-github-runner/aws-ssm-util'; import yn from 'yn'; -import { createGithubAppAuth, createGithubInstallationAuth, createOctokitClient } from '../github/auth'; +import { + createGithubAppAuth, + createGithubInstallationAuth, + createOctokitClient, + createEnterprisePATClient, +} from '../github/auth'; import { createRunner, listEC2Runners, tag, terminateRunner } from './../aws/runners'; -import { RunnerInputParameters } from './../aws/runners.d'; +import { RunnerInputParameters, RunnerType } from './../aws/runners.d'; import { metricGitHubAppRateLimit } from '../github/rate-limit'; import { publishRetryMessage } from './job-retry'; +import { resolveRunnerType } from './runner-config'; const logger = createChildLogger('scale-up'); @@ -50,7 +56,8 @@ interface CreateGitHubRunnerConfig { runnerGroup: string; runnerNamePrefix: string; runnerOwner: string; - runnerType: 'Org' | 'Repo'; + runnerType: RunnerType; + enterpriseSlug?: string; disableAutoUpdate: boolean; ssmTokenPath: string; ssmConfigPath: string; @@ -70,10 +77,14 @@ interface CreateEC2RunnerConfig { } function generateRunnerServiceConfig(githubRunnerConfig: CreateGitHubRunnerConfig, token: string) { - const config = [ - `--url ${githubRunnerConfig.ghesBaseUrl ?? 'https://github.com'}/${githubRunnerConfig.runnerOwner}`, - `--token ${token}`, - ]; + let runnerUrl: string; + if (githubRunnerConfig.runnerType === 'Enterprise' && githubRunnerConfig.enterpriseSlug) { + runnerUrl = `${githubRunnerConfig.ghesBaseUrl ?? 'https://github.com'}/enterprises/${githubRunnerConfig.enterpriseSlug}`; + } else { + runnerUrl = `${githubRunnerConfig.ghesBaseUrl ?? 'https://github.com'}/${githubRunnerConfig.runnerOwner}`; + } + + const config = [`--url ${runnerUrl}`, `--token ${token}`]; if (githubRunnerConfig.runnerLabels) { config.push(`--labels ${githubRunnerConfig.runnerLabels}`.trim()); @@ -83,7 +94,10 @@ function generateRunnerServiceConfig(githubRunnerConfig: CreateGitHubRunnerConfi config.push('--disableupdate'); } - if (githubRunnerConfig.runnerType === 'Org' && githubRunnerConfig.runnerGroup !== undefined) { + if ( + (githubRunnerConfig.runnerType === 'Org' || githubRunnerConfig.runnerType === 'Enterprise') && + githubRunnerConfig.runnerGroup !== undefined + ) { config.push(`--runnergroup ${githubRunnerConfig.runnerGroup}`); } @@ -127,12 +141,16 @@ export function validateSsmParameterStoreTags(tagsJson: string): { Key: string; async function getGithubRunnerRegistrationToken(githubRunnerConfig: CreateGitHubRunnerConfig, ghClient: Octokit) { const registrationToken = - githubRunnerConfig.runnerType === 'Org' - ? await ghClient.actions.createRegistrationTokenForOrg({ org: githubRunnerConfig.runnerOwner }) - : await ghClient.actions.createRegistrationTokenForRepo({ - owner: githubRunnerConfig.runnerOwner.split('/')[0], - repo: githubRunnerConfig.runnerOwner.split('/')[1], - }); + githubRunnerConfig.runnerType === 'Enterprise' + ? await ghClient.request('POST /enterprises/{enterprise}/actions/runners/registration-token', { + enterprise: githubRunnerConfig.enterpriseSlug!, + }) + : githubRunnerConfig.runnerType === 'Org' + ? await ghClient.actions.createRegistrationTokenForOrg({ org: githubRunnerConfig.runnerOwner }) + : await ghClient.actions.createRegistrationTokenForRepo({ + owner: githubRunnerConfig.runnerOwner.split('/')[0], + repo: githubRunnerConfig.runnerOwner.split('/')[1], + }); return registrationToken.data.token; } @@ -192,7 +210,10 @@ export async function isJobQueued(githubInstallationClient: Octokit, payload: Ac async function getRunnerGroupId(githubRunnerConfig: CreateGitHubRunnerConfig, ghClient: Octokit): Promise { // if the runnerType is Repo, then runnerGroupId is default to 1 let runnerGroupId: number | undefined = 1; - if (githubRunnerConfig.runnerType === 'Org' && githubRunnerConfig.runnerGroup !== undefined) { + if ( + (githubRunnerConfig.runnerType === 'Org' || githubRunnerConfig.runnerType === 'Enterprise') && + githubRunnerConfig.runnerGroup !== undefined + ) { let runnerGroup: string | undefined; // check if runner group id is already stored in SSM Parameter Store and // use it if it exists to avoid API call to GitHub @@ -232,10 +253,16 @@ async function getRunnerGroupId(githubRunnerConfig: CreateGitHubRunnerConfig, gh } async function getRunnerGroupByName(ghClient: Octokit, githubRunnerConfig: CreateGitHubRunnerConfig): Promise { - const runnerGroups: RunnerGroup[] = await ghClient.paginate(`GET /orgs/{org}/actions/runner-groups`, { - org: githubRunnerConfig.runnerOwner, - per_page: 100, - }); + const runnerGroups: RunnerGroup[] = + githubRunnerConfig.runnerType === 'Enterprise' + ? await ghClient.paginate(`GET /enterprises/{enterprise}/actions/runner-groups`, { + enterprise: githubRunnerConfig.enterpriseSlug!, + per_page: 100, + }) + : await ghClient.paginate(`GET /orgs/{org}/actions/runner-groups`, { + org: githubRunnerConfig.runnerOwner, + per_page: 100, + }); const runnerGroupId = runnerGroups.find((runnerGroup) => runnerGroup.name === githubRunnerConfig.runnerGroup)?.id; if (runnerGroupId === undefined) { @@ -255,6 +282,7 @@ export async function createRunners( const instances = await createRunner({ runnerType: githubRunnerConfig.runnerType, runnerOwner: githubRunnerConfig.runnerOwner, + enterpriseSlug: githubRunnerConfig.enterpriseSlug, numberOfRunners, source, ...ec2RunnerConfig, @@ -293,7 +321,9 @@ export async function scaleUp(payloads: ActionRequestMessageSQS[]): Promise(); const rejectedMessageIds = new Set(); for (const payload of payloads) { @@ -354,7 +395,7 @@ export async function scaleUp(payloads: ActionRequestMessageSQS[]): Promise [cloudwatch\_config](#input\_cloudwatch\_config) | (optional) Replaces the module default cloudwatch log config. See https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/CloudWatch-Agent-Configuration-File-Details.html for details. | `string` | `null` | no | | [enable\_ami\_housekeeper](#input\_enable\_ami\_housekeeper) | Option to disable the lambda to clean up old AMIs. | `bool` | `false` | no | | [enable\_managed\_runner\_security\_group](#input\_enable\_managed\_runner\_security\_group) | Enabling the default managed security group creation. Unmanaged security groups can be specified via `runner_additional_security_group_ids`. | `bool` | `true` | no | +| [enterprise\_pat](#input\_enterprise\_pat) | Enterprise Personal Access Token(s) (PAT) for authenticating with GitHub Enterprise runner management APIs.
Required when runner\_registration\_level is "enterprise". Each PAT must have the 'manage\_runners:enterprise' scope.
You can either provide the PAT value directly (Terraform creates the SSM parameter) or reference a pre-existing SSM parameter.
Note: the provided SSM parameter arn and name take precedence over the direct value.

To distribute API calls across multiple PATs and avoid rate limiting, provide a comma-separated list of PATs
in the 'pat' field or in the SSM parameter value. The Lambda functions will randomly select one PAT per invocation.
Example: enterprise\_pat = { pat = "ghp\_token1,ghp\_token2,ghp\_token3" } |
object({
pat = optional(string)
pat_ssm = optional(object({
arn = string
name = string
}))
})
| `null` | no | +| [enterprise\_slug](#input\_enterprise\_slug) | The slug (URL identifier) of the GitHub Enterprise account. Required when runner\_registration\_level is "enterprise". Example: "my-enterprise". | `string` | `null` | no | | [eventbridge](#input\_eventbridge) | Enable the use of EventBridge by the module. By enabling this feature events will be put on the EventBridge by the webhook instead of directly dispatching to queues for scaling. |
object({
enable = optional(bool, true)
accept_events = optional(list(string), [])
})
| `{}` | no | | [ghes\_ssl\_verify](#input\_ghes\_ssl\_verify) | GitHub Enterprise SSL verification. Set to 'false' when custom certificate (chains) is used for GitHub Enterprise Server (insecure). | `bool` | `true` | no | | [ghes\_url](#input\_ghes\_url) | GitHub Enterprise Server URL. Example: https://github.internal.co - DO NOT SET IF USING PUBLIC GITHUB. .However if you are using GitHub Enterprise Cloud with data-residency (ghe.com), set the endpoint here. Example - https://companyname.ghe.com\| | `string` | `null` | no | -| [github\_app](#input\_github\_app) | GitHub app parameters, see your github app.
You can optionally create the SSM parameters yourself and provide the ARN and name here, through the `*_ssm` attributes.
If you chose to provide the configuration values directly here,
please ensure the key is the base64-encoded `.pem` file (the output of `base64 app.private-key.pem`, not the content of `private-key.pem`).
Note: the provided SSM parameters arn and name have a precedence over the actual value (i.e `key_base64_ssm` has a precedence over `key_base64` etc). |
object({
key_base64 = optional(string)
key_base64_ssm = optional(object({
arn = string
name = string
}))
id = optional(string)
id_ssm = optional(object({
arn = string
name = string
}))
webhook_secret = optional(string)
webhook_secret_ssm = optional(object({
arn = string
name = string
}))
})
| n/a | yes | +| [github\_app](#input\_github\_app) | GitHub app parameters, see your github app.
You can optionally create the SSM parameters yourself and provide the ARN and name here, through the `*_ssm` attributes.
If you chose to provide the configuration values directly here,
please ensure the key is the base64-encoded `.pem` file (the output of `base64 app.private-key.pem`, not the content of `private-key.pem`).
Note: the provided SSM parameters arn and name have a precedence over the actual value (i.e `key_base64_ssm` has a precedence over `key_base64` etc).

For enterprise runners (runner\_registration\_level = "enterprise"), only `webhook_secret` (or `webhook_secret_ssm`) is required.
The `key_base64` and `id` fields are only needed for org/repo level runners. |
object({
key_base64 = optional(string)
key_base64_ssm = optional(object({
arn = string
name = string
}))
id = optional(string)
id_ssm = optional(object({
arn = string
name = string
}))
webhook_secret = optional(string)
webhook_secret_ssm = optional(object({
arn = string
name = string
}))
})
| n/a | yes | | [instance\_profile\_path](#input\_instance\_profile\_path) | The path that will be added to the instance\_profile, if not set the environment name will be used. | `string` | `null` | no | | [instance\_termination\_watcher](#input\_instance\_termination\_watcher) | Configuration for the spot termination watcher lambda function. This feature is Beta, changes will not trigger a major release as long in beta.

`enable`: Enable or disable the spot termination watcher.
`memory_size`: Memory size limit in MB of the lambda.
`s3_key`: S3 key for syncer lambda function. Required if using S3 bucket to specify lambdas.
`s3_object_version`: S3 object version for syncer lambda function. Useful if S3 versioning is enabled on source bucket.
`timeout`: Time out of the lambda in seconds.
`zip`: File location of the lambda zip file. |
object({
enable = optional(bool, false)
features = optional(object({
enable_spot_termination_handler = optional(bool, true)
enable_spot_termination_notification_watcher = optional(bool, true)
}), {})
memory_size = optional(number, null)
s3_key = optional(string, null)
s3_object_version = optional(string, null)
timeout = optional(number, null)
zip = optional(string, null)
})
| `{}` | no | | [key\_name](#input\_key\_name) | Key pair name | `string` | `null` | no | @@ -151,7 +153,7 @@ module "multi-runner" { | [logging\_retention\_in\_days](#input\_logging\_retention\_in\_days) | Specifies the number of days you want to retain log events for the lambda log group. Possible values are: 0, 1, 3, 5, 7, 14, 30, 60, 90, 120, 150, 180, 365, 400, 545, 731, 1827, and 3653. | `number` | `180` | no | | [matcher\_config\_parameter\_store\_tier](#input\_matcher\_config\_parameter\_store\_tier) | The tier of the parameter store for the matcher configuration. Valid values are `Standard`, and `Advanced`. | `string` | `"Standard"` | no | | [metrics](#input\_metrics) | Configuration for metrics created by the module, by default metrics are disabled to avoid additional costs. When metrics are enable all metrics are created unless explicit configured otherwise. |
object({
enable = optional(bool, false)
namespace = optional(string, "GitHub Runners")
metric = optional(object({
enable_github_app_rate_limit = optional(bool, true)
enable_job_retry = optional(bool, true)
enable_spot_termination_warning = optional(bool, true)
}), {})
})
| `{}` | no | -| [multi\_runner\_config](#input\_multi\_runner\_config) | multi\_runner\_config = {
runner\_config: {
runner\_os: "The EC2 Operating System type to use for action runner instances (linux,windows)."
runner\_architecture: "The platform architecture of the runner instance\_type."
runner\_metadata\_options: "(Optional) Metadata options for the ec2 runner instances."
ami: "(Optional) AMI configuration for the action runner instances. This object allows you to specify all AMI-related settings in one place."
create\_service\_linked\_role\_spot: (Optional) create the serviced linked role for spot instances that is required by the scale-up lambda.
credit\_specification: "(Optional) The credit specification of the runner instance\_type. Can be unset, `standard` or `unlimited`.
delay\_webhook\_event: "The number of seconds the event accepted by the webhook is invisible on the queue before the scale up lambda will receive the event."
disable\_runner\_autoupdate: "Disable the auto update of the github runner agent. Be aware there is a grace period of 30 days, see also the [GitHub article](https://github.blog/changelog/2022-02-01-github-actions-self-hosted-runners-can-now-disable-automatic-updates/)"
ebs\_optimized: "The EC2 EBS optimized configuration."
enable\_ephemeral\_runners: "Enable ephemeral runners, runners will only be used once."
enable\_job\_queued\_check: "Enables JIT configuration for creating runners instead of registration token based registraton. JIT configuration will only be applied for ephemeral runners. By default JIT configuration is enabled for ephemeral runners an can be disabled via this override. When running on GHES without support for JIT configuration this variable should be set to true for ephemeral runners."
enable\_on\_demand\_failover\_for\_errors: "Enable on-demand failover. For example to fall back to on demand when no spot capacity is available the variable can be set to `InsufficientInstanceCapacity`. When not defined the default behavior is to retry later."
scale\_errors: "List of aws error codes that should trigger retry during scale up. This list will replace the default errors defined in the variable `defaultScaleErrors` in https://github.com/github-aws-runners/terraform-aws-github-runner/blob/main/lambdas/functions/control-plane/src/aws/runners.ts"
enable\_organization\_runners: "Register runners to organization, instead of repo level"
enable\_runner\_binaries\_syncer: "Option to disable the lambda to sync GitHub runner distribution, useful when using a pre-build AMI."
enable\_ssm\_on\_runners: "Enable to allow access the runner instances for debugging purposes via SSM. Note that this adds additional permissions to the runner instances."
enable\_userdata: "Should the userdata script be enabled for the runner. Set this to false if you are using your own prebuilt AMI."
instance\_allocation\_strategy: "The allocation strategy for spot instances. AWS recommends to use `capacity-optimized` however the AWS default is `lowest-price`."
instance\_max\_spot\_price: "Max price price for spot instances per hour. This variable will be passed to the create fleet as max spot price for the fleet."
instance\_target\_capacity\_type: "Default lifecycle used for runner instances, can be either `spot` or `on-demand`."
instance\_types: "List of instance types for the action runner. Defaults are based on runner\_os (al2023 for linux and Windows Server Core for win)."
job\_queue\_retention\_in\_seconds: "The number of seconds the job is held in the queue before it is purged"
minimum\_running\_time\_in\_minutes: "The time an ec2 action runner should be running at minimum before terminated if not busy."
pool\_runner\_owner: "The pool will deploy runners to the GitHub org ID, set this value to the org to which you want the runners deployed. Repo level is not supported."
runner\_additional\_security\_group\_ids: "List of additional security groups IDs to apply to the runner. If added outside the multi\_runner\_config block, the additional security group(s) will be applied to all runner configs. If added inside the multi\_runner\_config, the additional security group(s) will be applied to the individual runner."
runner\_as\_root: "Run the action runner under the root user. Variable `runner_run_as` will be ignored."
runner\_boot\_time\_in\_minutes: "The minimum time for an EC2 runner to boot and register as a runner."
runner\_disable\_default\_labels: "Disable default labels for the runners (os, architecture and `self-hosted`). If enabled, the runner will only have the extra labels provided in `runner_extra_labels`. In case you on own start script is used, this configuration parameter needs to be parsed via SSM."
runner\_extra\_labels: "Extra (custom) labels for the runners (GitHub). Separate each label by a comma. Labels checks on the webhook can be enforced by setting `multi_runner_config.matcherConfig.exactMatch`. GitHub read-only labels should not be provided."
runner\_group\_name: "Name of the runner group."
runner\_name\_prefix: "Prefix for the GitHub runner name."
runner\_run\_as: "Run the GitHub actions agent as user."
runners\_maximum\_count: "The maximum number of runners that will be created. Setting the variable to `-1` desiables the maximum check."
scale\_down\_schedule\_expression: "Scheduler expression to check every x for scale down."
scale\_up\_reserved\_concurrent\_executions: "Amount of reserved concurrent executions for the scale-up lambda function. A value of 0 disables lambda from being triggered and -1 removes any concurrency limitations."
userdata\_template: "Alternative user-data template, replacing the default template. By providing your own user\_data you have to take care of installing all required software, including the action runner. Variables userdata\_pre/post\_install are ignored."
enable\_jit\_config "Overwrite the default behavior for JIT configuration. By default JIT configuration is enabled for ephemeral runners and disabled for non-ephemeral runners. In case of GHES check first if the JIT config API is available. In case you are upgrading from 3.x to 4.x you can set `enable_jit_config` to `false` to avoid a breaking change when having your own AMI."
enable\_runner\_detailed\_monitoring: "Should detailed monitoring be enabled for the runner. Set this to true if you want to use detailed monitoring. See https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/using-cloudwatch-new.html for details."
enable\_cloudwatch\_agent: "Enabling the cloudwatch agent on the ec2 runner instances, the runner contains default config. Configuration can be overridden via `cloudwatch_config`."
cloudwatch\_config: "(optional) Replaces the module default cloudwatch log config. See https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/CloudWatch-Agent-Configuration-File-Details.html for details."
userdata\_pre\_install: "Script to be ran before the GitHub Actions runner is installed on the EC2 instances"
userdata\_post\_install: "Script to be ran after the GitHub Actions runner is installed on the EC2 instances"
runner\_hook\_job\_started: "Script to be ran in the runner environment at the beginning of every job"
runner\_hook\_job\_completed: "Script to be ran in the runner environment at the end of every job"
runner\_ec2\_tags: "Map of tags that will be added to the launch template instance tag specifications."
runner\_iam\_role\_managed\_policy\_arns: "Attach AWS or customer-managed IAM policies (by ARN) to the runner IAM role"
vpc\_id: "The VPC for security groups of the action runners. If not set uses the value of `var.vpc_id`."
subnet\_ids: "List of subnets in which the action runners will be launched, the subnets needs to be subnets in the `vpc_id`. If not set, uses the value of `var.subnet_ids`."
idle\_config: "List of time period that can be defined as cron expression to keep a minimum amount of runners active instead of scaling down to 0. By defining this list you can ensure that in time periods that match the cron expression within 5 seconds a runner is kept idle."
runner\_log\_files: "(optional) Replaces the module default cloudwatch log config. See https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/CloudWatch-Agent-Configuration-File-Details.html for details."
block\_device\_mappings: "The EC2 instance block device configuration. Takes the following keys: `device_name`, `delete_on_termination`, `volume_type`, `volume_size`, `encrypted`, `iops`, `throughput`, `kms_key_id`, `snapshot_id`."
job\_retry: "Experimental! Can be removed / changed without trigger a major release. Configure job retries. The configuration enables job retries (for ephemeral runners). After creating the instances a message will be published to a job retry queue. The job retry check lambda is checking after a delay if the job is queued. If not the message will be published again on the scale-up (build queue). Using this feature can impact the rate limit of the GitHub app."
pool\_config: "The configuration for updating the pool. The `pool_size` to adjust to by the events triggered by the `schedule_expression`. For example you can configure a cron expression for week days to adjust the pool to 10 and another expression for the weekend to adjust the pool to 1. Use `schedule_expression_timezone` to override the schedule time zone (defaults to UTC)."
}
matcherConfig: {
labelMatchers: "The list of list of labels supported by the runner configuration. `[[self-hosted, linux, x64, example]]`"
exactMatch: "If set to true all labels in the workflow job must match the GitHub labels (os, architecture and `self-hosted`). When false if __any__ workflow label matches it will trigger the webhook."
priority: "If set it defines the priority of the matcher, the matcher with the lowest priority will be evaluated first. Default is 999, allowed values 0-999."
}
redrive\_build\_queue: "Set options to attach (optional) a dead letter queue to the build queue, the queue between the webhook and the scale up lambda. You have the following options. 1. Disable by setting `enabled` to false. 2. Enable by setting `enabled` to `true`, `maxReceiveCount` to a number of max retries."
} |
map(object({
runner_config = object({
runner_os = string
runner_architecture = string
runner_metadata_options = optional(map(any), {
instance_metadata_tags = "enabled"
http_endpoint = "enabled"
http_tokens = "required"
http_put_response_hop_limit = 1
})
ami = optional(object({
filter = optional(map(list(string)), { state = ["available"] })
owners = optional(list(string), ["amazon"])
id_ssm_parameter_arn = optional(string, null)
kms_key_arn = optional(string, null)
}), null)
create_service_linked_role_spot = optional(bool, false)
credit_specification = optional(string, null)
delay_webhook_event = optional(number, 30)
disable_runner_autoupdate = optional(bool, false)
ebs_optimized = optional(bool, false)
enable_ephemeral_runners = optional(bool, false)
enable_job_queued_check = optional(bool, null)
enable_on_demand_failover_for_errors = optional(list(string), [])
scale_errors = optional(list(string), [
"UnfulfillableCapacity",
"MaxSpotInstanceCountExceeded",
"TargetCapacityLimitExceededException",
"RequestLimitExceeded",
"ResourceLimitExceeded",
"MaxSpotInstanceCountExceeded",
"MaxSpotFleetRequestCountExceeded",
"InsufficientInstanceCapacity",
"InsufficientCapacityOnHost",
])
enable_organization_runners = optional(bool, false)
enable_runner_binaries_syncer = optional(bool, true)
enable_ssm_on_runners = optional(bool, false)
enable_userdata = optional(bool, true)
instance_allocation_strategy = optional(string, "lowest-price")
instance_max_spot_price = optional(string, null)
instance_target_capacity_type = optional(string, "spot")
instance_types = list(string)
job_queue_retention_in_seconds = optional(number, 86400)
minimum_running_time_in_minutes = optional(number, null)
pool_runner_owner = optional(string, null)
runner_as_root = optional(bool, false)
runner_boot_time_in_minutes = optional(number, 5)
runner_disable_default_labels = optional(bool, false)
runner_extra_labels = optional(list(string), [])
runner_group_name = optional(string, "Default")
runner_name_prefix = optional(string, "")
runner_run_as = optional(string, "ec2-user")
runners_maximum_count = number
runner_additional_security_group_ids = optional(list(string), [])
scale_down_schedule_expression = optional(string, "cron(*/5 * * * ? *)")
scale_up_reserved_concurrent_executions = optional(number, 1)
userdata_template = optional(string, null)
userdata_content = optional(string, null)
enable_jit_config = optional(bool, null)
enable_runner_detailed_monitoring = optional(bool, false)
enable_cloudwatch_agent = optional(bool, true)
cloudwatch_config = optional(string, null)
userdata_pre_install = optional(string, "")
userdata_post_install = optional(string, "")
runner_hook_job_started = optional(string, "")
runner_hook_job_completed = optional(string, "")
runner_ec2_tags = optional(map(string), {})
runner_iam_role_managed_policy_arns = optional(list(string), [])
vpc_id = optional(string, null)
subnet_ids = optional(list(string), null)
idle_config = optional(list(object({
cron = string
timeZone = string
idleCount = number
evictionStrategy = optional(string, "oldest_first")
})), [])
cpu_options = optional(object({
core_count = number
threads_per_core = number
}), null)
placement = optional(object({
affinity = optional(string)
availability_zone = optional(string)
group_id = optional(string)
group_name = optional(string)
host_id = optional(string)
host_resource_group_arn = optional(string)
spread_domain = optional(string)
tenancy = optional(string)
partition_number = optional(number)
}), null)
runner_log_files = optional(list(object({
log_group_name = string
prefix_log_group = bool
file_path = string
log_stream_name = string
log_class = optional(string, "STANDARD")
})), null)
block_device_mappings = optional(list(object({
delete_on_termination = optional(bool, true)
device_name = optional(string, "/dev/xvda")
encrypted = optional(bool, true)
iops = optional(number)
kms_key_id = optional(string)
snapshot_id = optional(string)
throughput = optional(number)
volume_size = number
volume_type = optional(string, "gp3")
})), [{
volume_size = 30
}])
pool_config = optional(list(object({
schedule_expression = string
schedule_expression_timezone = optional(string)
size = number
})), [])
job_retry = optional(object({
enable = optional(bool, false)
delay_in_seconds = optional(number, 300)
delay_backoff = optional(number, 2)
lambda_memory_size = optional(number, 256)
lambda_timeout = optional(number, 30)
max_attempts = optional(number, 1)
}), {})
})
matcherConfig = object({
labelMatchers = list(list(string))
exactMatch = optional(bool, false)
priority = optional(number, 999)
})
redrive_build_queue = optional(object({
enabled = bool
maxReceiveCount = number
}), {
enabled = false
maxReceiveCount = null
})
}))
| n/a | yes | +| [multi\_runner\_config](#input\_multi\_runner\_config) | multi\_runner\_config = {
runner\_config: {
runner\_os: "The EC2 Operating System type to use for action runner instances (linux,windows)."
runner\_architecture: "The platform architecture of the runner instance\_type."
runner\_metadata\_options: "(Optional) Metadata options for the ec2 runner instances."
ami: "(Optional) AMI configuration for the action runner instances. This object allows you to specify all AMI-related settings in one place."
create\_service\_linked\_role\_spot: (Optional) create the serviced linked role for spot instances that is required by the scale-up lambda.
credit\_specification: "(Optional) The credit specification of the runner instance\_type. Can be unset, `standard` or `unlimited`.
delay\_webhook\_event: "The number of seconds the event accepted by the webhook is invisible on the queue before the scale up lambda will receive the event."
disable\_runner\_autoupdate: "Disable the auto update of the github runner agent. Be aware there is a grace period of 30 days, see also the [GitHub article](https://github.blog/changelog/2022-02-01-github-actions-self-hosted-runners-can-now-disable-automatic-updates/)"
ebs\_optimized: "The EC2 EBS optimized configuration."
enable\_ephemeral\_runners: "Enable ephemeral runners, runners will only be used once."
enable\_job\_queued\_check: "Enables JIT configuration for creating runners instead of registration token based registraton. JIT configuration will only be applied for ephemeral runners. By default JIT configuration is enabled for ephemeral runners an can be disabled via this override. When running on GHES without support for JIT configuration this variable should be set to true for ephemeral runners."
enable\_on\_demand\_failover\_for\_errors: "Enable on-demand failover. For example to fall back to on demand when no spot capacity is available the variable can be set to `InsufficientInstanceCapacity`. When not defined the default behavior is to retry later."
scale\_errors: "List of aws error codes that should trigger retry during scale up. This list will replace the default errors defined in the variable `defaultScaleErrors` in https://github.com/github-aws-runners/terraform-aws-github-runner/blob/main/lambdas/functions/control-plane/src/aws/runners.ts"
enable\_organization\_runners: "Register runners to organization, instead of repo level"
enable\_runner\_binaries\_syncer: "Option to disable the lambda to sync GitHub runner distribution, useful when using a pre-build AMI."
enable\_ssm\_on\_runners: "Enable to allow access the runner instances for debugging purposes via SSM. Note that this adds additional permissions to the runner instances."
enable\_userdata: "Should the userdata script be enabled for the runner. Set this to false if you are using your own prebuilt AMI."
instance\_allocation\_strategy: "The allocation strategy for spot instances. AWS recommends to use `capacity-optimized` however the AWS default is `lowest-price`."
instance\_max\_spot\_price: "Max price price for spot instances per hour. This variable will be passed to the create fleet as max spot price for the fleet."
instance\_target\_capacity\_type: "Default lifecycle used for runner instances, can be either `spot` or `on-demand`."
instance\_types: "List of instance types for the action runner. Defaults are based on runner\_os (al2023 for linux and Windows Server Core for win)."
job\_queue\_retention\_in\_seconds: "The number of seconds the job is held in the queue before it is purged"
minimum\_running\_time\_in\_minutes: "The time an ec2 action runner should be running at minimum before terminated if not busy."
pool\_runner\_owner: "The pool will deploy runners to the GitHub org ID, set this value to the org to which you want the runners deployed. Repo level is not supported. For enterprise-level runners, defaults to the enterprise\_slug if not set."
runner\_additional\_security\_group\_ids: "List of additional security groups IDs to apply to the runner. If added outside the multi\_runner\_config block, the additional security group(s) will be applied to all runner configs. If added inside the multi\_runner\_config, the additional security group(s) will be applied to the individual runner."
runner\_as\_root: "Run the action runner under the root user. Variable `runner_run_as` will be ignored."
runner\_boot\_time\_in\_minutes: "The minimum time for an EC2 runner to boot and register as a runner."
runner\_disable\_default\_labels: "Disable default labels for the runners (os, architecture and `self-hosted`). If enabled, the runner will only have the extra labels provided in `runner_extra_labels`. In case you on own start script is used, this configuration parameter needs to be parsed via SSM."
runner\_extra\_labels: "Extra (custom) labels for the runners (GitHub). Separate each label by a comma. Labels checks on the webhook can be enforced by setting `multi_runner_config.matcherConfig.exactMatch`. GitHub read-only labels should not be provided."
runner\_group\_name: "Name of the runner group."
runner\_name\_prefix: "Prefix for the GitHub runner name."
runner\_run\_as: "Run the GitHub actions agent as user."
runners\_maximum\_count: "The maximum number of runners that will be created. Setting the variable to `-1` desiables the maximum check."
scale\_down\_schedule\_expression: "Scheduler expression to check every x for scale down."
scale\_up\_reserved\_concurrent\_executions: "Amount of reserved concurrent executions for the scale-up lambda function. A value of 0 disables lambda from being triggered and -1 removes any concurrency limitations."
userdata\_template: "Alternative user-data template, replacing the default template. By providing your own user\_data you have to take care of installing all required software, including the action runner. Variables userdata\_pre/post\_install are ignored."
enable\_jit\_config "Overwrite the default behavior for JIT configuration. By default JIT configuration is enabled for ephemeral runners and disabled for non-ephemeral runners. In case of GHES check first if the JIT config API is available. In case you are upgrading from 3.x to 4.x you can set `enable_jit_config` to `false` to avoid a breaking change when having your own AMI."
enable\_runner\_detailed\_monitoring: "Should detailed monitoring be enabled for the runner. Set this to true if you want to use detailed monitoring. See https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/using-cloudwatch-new.html for details."
enable\_cloudwatch\_agent: "Enabling the cloudwatch agent on the ec2 runner instances, the runner contains default config. Configuration can be overridden via `cloudwatch_config`."
cloudwatch\_config: "(optional) Replaces the module default cloudwatch log config. See https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/CloudWatch-Agent-Configuration-File-Details.html for details."
userdata\_pre\_install: "Script to be ran before the GitHub Actions runner is installed on the EC2 instances"
userdata\_post\_install: "Script to be ran after the GitHub Actions runner is installed on the EC2 instances"
runner\_hook\_job\_started: "Script to be ran in the runner environment at the beginning of every job"
runner\_hook\_job\_completed: "Script to be ran in the runner environment at the end of every job"
runner\_ec2\_tags: "Map of tags that will be added to the launch template instance tag specifications."
runner\_iam\_role\_managed\_policy\_arns: "Attach AWS or customer-managed IAM policies (by ARN) to the runner IAM role"
vpc\_id: "The VPC for security groups of the action runners. If not set uses the value of `var.vpc_id`."
subnet\_ids: "List of subnets in which the action runners will be launched, the subnets needs to be subnets in the `vpc_id`. If not set, uses the value of `var.subnet_ids`."
idle\_config: "List of time period that can be defined as cron expression to keep a minimum amount of runners active instead of scaling down to 0. By defining this list you can ensure that in time periods that match the cron expression within 5 seconds a runner is kept idle."
runner\_log\_files: "(optional) Replaces the module default cloudwatch log config. See https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/CloudWatch-Agent-Configuration-File-Details.html for details."
block\_device\_mappings: "The EC2 instance block device configuration. Takes the following keys: `device_name`, `delete_on_termination`, `volume_type`, `volume_size`, `encrypted`, `iops`, `throughput`, `kms_key_id`, `snapshot_id`."
job\_retry: "Experimental! Can be removed / changed without trigger a major release. Configure job retries. The configuration enables job retries (for ephemeral runners). After creating the instances a message will be published to a job retry queue. The job retry check lambda is checking after a delay if the job is queued. If not the message will be published again on the scale-up (build queue). Using this feature can impact the rate limit of the GitHub app."
pool\_config: "The configuration for updating the pool. The `pool_size` to adjust to by the events triggered by the `schedule_expression`. For example you can configure a cron expression for week days to adjust the pool to 10 and another expression for the weekend to adjust the pool to 1. Use `schedule_expression_timezone` to override the schedule time zone (defaults to UTC)."
}
matcherConfig: {
labelMatchers: "The list of list of labels supported by the runner configuration. `[[self-hosted, linux, x64, example]]`"
exactMatch: "If set to true all labels in the workflow job must match the GitHub labels (os, architecture and `self-hosted`). When false if __any__ workflow label matches it will trigger the webhook."
priority: "If set it defines the priority of the matcher, the matcher with the lowest priority will be evaluated first. Default is 999, allowed values 0-999."
}
redrive\_build\_queue: "Set options to attach (optional) a dead letter queue to the build queue, the queue between the webhook and the scale up lambda. You have the following options. 1. Disable by setting `enabled` to false. 2. Enable by setting `enabled` to `true`, `maxReceiveCount` to a number of max retries."
} |
map(object({
runner_config = object({
runner_os = string
runner_architecture = string
runner_metadata_options = optional(map(any), {
instance_metadata_tags = "enabled"
http_endpoint = "enabled"
http_tokens = "required"
http_put_response_hop_limit = 1
})
ami = optional(object({
filter = optional(map(list(string)), { state = ["available"] })
owners = optional(list(string), ["amazon"])
id_ssm_parameter_arn = optional(string, null)
kms_key_arn = optional(string, null)
}), null)
create_service_linked_role_spot = optional(bool, false)
credit_specification = optional(string, null)
delay_webhook_event = optional(number, 30)
disable_runner_autoupdate = optional(bool, false)
ebs_optimized = optional(bool, false)
enable_ephemeral_runners = optional(bool, false)
enable_job_queued_check = optional(bool, null)
enable_on_demand_failover_for_errors = optional(list(string), [])
scale_errors = optional(list(string), [
"UnfulfillableCapacity",
"MaxSpotInstanceCountExceeded",
"TargetCapacityLimitExceededException",
"RequestLimitExceeded",
"ResourceLimitExceeded",
"MaxSpotInstanceCountExceeded",
"MaxSpotFleetRequestCountExceeded",
"InsufficientInstanceCapacity",
"InsufficientCapacityOnHost",
])
enable_organization_runners = optional(bool, false) # DEPRECATED: use runner_registration_level
runner_registration_level = optional(string, null)
enable_runner_binaries_syncer = optional(bool, true)
enable_ssm_on_runners = optional(bool, false)
enable_userdata = optional(bool, true)
instance_allocation_strategy = optional(string, "lowest-price")
instance_max_spot_price = optional(string, null)
instance_target_capacity_type = optional(string, "spot")
instance_types = list(string)
job_queue_retention_in_seconds = optional(number, 86400)
minimum_running_time_in_minutes = optional(number, null)
pool_runner_owner = optional(string, null)
runner_as_root = optional(bool, false)
runner_boot_time_in_minutes = optional(number, 5)
runner_disable_default_labels = optional(bool, false)
runner_extra_labels = optional(list(string), [])
runner_group_name = optional(string, "Default")
runner_name_prefix = optional(string, "")
runner_run_as = optional(string, "ec2-user")
runners_maximum_count = number
runner_additional_security_group_ids = optional(list(string), [])
scale_down_schedule_expression = optional(string, "cron(*/5 * * * ? *)")
scale_up_reserved_concurrent_executions = optional(number, 1)
userdata_template = optional(string, null)
userdata_content = optional(string, null)
enable_jit_config = optional(bool, null)
enable_runner_detailed_monitoring = optional(bool, false)
enable_cloudwatch_agent = optional(bool, true)
cloudwatch_config = optional(string, null)
userdata_pre_install = optional(string, "")
userdata_post_install = optional(string, "")
runner_hook_job_started = optional(string, "")
runner_hook_job_completed = optional(string, "")
runner_ec2_tags = optional(map(string), {})
runner_iam_role_managed_policy_arns = optional(list(string), [])
vpc_id = optional(string, null)
subnet_ids = optional(list(string), null)
idle_config = optional(list(object({
cron = string
timeZone = string
idleCount = number
evictionStrategy = optional(string, "oldest_first")
})), [])
cpu_options = optional(object({
core_count = number
threads_per_core = number
}), null)
placement = optional(object({
affinity = optional(string)
availability_zone = optional(string)
group_id = optional(string)
group_name = optional(string)
host_id = optional(string)
host_resource_group_arn = optional(string)
spread_domain = optional(string)
tenancy = optional(string)
partition_number = optional(number)
}), null)
runner_log_files = optional(list(object({
log_group_name = string
prefix_log_group = bool
file_path = string
log_stream_name = string
log_class = optional(string, "STANDARD")
})), null)
block_device_mappings = optional(list(object({
delete_on_termination = optional(bool, true)
device_name = optional(string, "/dev/xvda")
encrypted = optional(bool, true)
iops = optional(number)
kms_key_id = optional(string)
snapshot_id = optional(string)
throughput = optional(number)
volume_size = number
volume_type = optional(string, "gp3")
})), [{
volume_size = 30
}])
pool_config = optional(list(object({
schedule_expression = string
schedule_expression_timezone = optional(string)
size = number
})), [])
job_retry = optional(object({
enable = optional(bool, false)
delay_in_seconds = optional(number, 300)
delay_backoff = optional(number, 2)
lambda_memory_size = optional(number, 256)
lambda_timeout = optional(number, 30)
max_attempts = optional(number, 1)
}), {})
})
matcherConfig = object({
labelMatchers = list(list(string))
exactMatch = optional(bool, false)
priority = optional(number, 999)
})
redrive_build_queue = optional(object({
enabled = bool
maxReceiveCount = number
}), {
enabled = false
maxReceiveCount = null
})
}))
| n/a | yes | | [parameter\_store\_tags](#input\_parameter\_store\_tags) | Map of tags that will be added to all the SSM Parameter Store parameters created by the Lambda function. | `map(string)` | `{}` | no | | [pool\_lambda\_reserved\_concurrent\_executions](#input\_pool\_lambda\_reserved\_concurrent\_executions) | Amount of reserved concurrent executions for the scale-up lambda function. A value of 0 disables lambda from being triggered and -1 removes any concurrency limitations. | `number` | `1` | no | | [pool\_lambda\_timeout](#input\_pool\_lambda\_timeout) | Time out for the pool lambda in seconds. | `number` | `60` | no | diff --git a/modules/multi-runner/main.tf b/modules/multi-runner/main.tf index 905cc7f793..045f098af1 100644 --- a/modules/multi-runner/main.tf +++ b/modules/multi-runner/main.tf @@ -4,11 +4,22 @@ locals { }) github_app_parameters = { - id = coalesce(var.github_app.id_ssm, module.ssm.parameters.github_app_id) - key_base64 = coalesce(var.github_app.key_base64_ssm, module.ssm.parameters.github_app_key_base64) + id = coalesce(var.github_app.id_ssm, module.ssm.parameters.github_app_id, { name = "", arn = "" }) + key_base64 = coalesce(var.github_app.key_base64_ssm, module.ssm.parameters.github_app_key_base64, { name = "", arn = "" }) webhook_secret = coalesce(var.github_app.webhook_secret_ssm, module.ssm.parameters.github_app_webhook_secret) } + enterprise_pat_parameters = var.enterprise_pat != null ? { + name = coalesce( + try(var.enterprise_pat.pat_ssm.name, null), + try(module.ssm.parameters.enterprise_pat.name, null) + ) + arn = coalesce( + try(var.enterprise_pat.pat_ssm.arn, null), + try(module.ssm.parameters.enterprise_pat.arn, null) + ) + } : null + runner_extra_labels = { for k, v in var.multi_runner_config : k => sort(setunion(flatten(v.matcherConfig.labelMatchers), compact(v.runner_config.runner_extra_labels))) } runner_config = { for k, v in var.multi_runner_config : k => merge({ id = aws_sqs_queue.queued_builds[k].id, arn = aws_sqs_queue.queued_builds[k].arn, url = aws_sqs_queue.queued_builds[k].url }, merge(v, { runner_config = merge(v.runner_config, { runner_extra_labels = local.runner_extra_labels[k] }) })) } diff --git a/modules/multi-runner/runners.tf b/modules/multi-runner/runners.tf index 59b6307aa0..576b6bd6d9 100644 --- a/modules/multi-runner/runners.tf +++ b/modules/multi-runner/runners.tf @@ -33,7 +33,9 @@ module "runners" { ebs_optimized = each.value.runner_config.ebs_optimized enable_on_demand_failover_for_errors = each.value.runner_config.enable_on_demand_failover_for_errors scale_errors = each.value.runner_config.scale_errors - enable_organization_runners = each.value.runner_config.enable_organization_runners + runner_registration_level = coalesce(each.value.runner_config.runner_registration_level, "repo") + enterprise_slug = var.enterprise_slug + enterprise_pat_parameter = local.enterprise_pat_parameters enable_ephemeral_runners = each.value.runner_config.enable_ephemeral_runners enable_jit_config = each.value.runner_config.enable_jit_config enable_job_queued_check = each.value.runner_config.enable_job_queued_check @@ -114,7 +116,7 @@ module "runners" { pool_config = each.value.runner_config.pool_config pool_lambda_timeout = var.pool_lambda_timeout - pool_runner_owner = each.value.runner_config.pool_runner_owner + pool_runner_owner = try(coalesce(each.value.runner_config.pool_runner_owner, var.enterprise_slug), null) pool_lambda_reserved_concurrent_executions = var.pool_lambda_reserved_concurrent_executions associate_public_ipv4_address = var.associate_public_ipv4_address diff --git a/modules/multi-runner/ssm.tf b/modules/multi-runner/ssm.tf index 6a3a234e6f..4ff164b5d2 100644 --- a/modules/multi-runner/ssm.tf +++ b/modules/multi-runner/ssm.tf @@ -1,7 +1,8 @@ module "ssm" { - source = "../ssm" - kms_key_arn = var.kms_key_arn - path_prefix = "${local.ssm_root_path}/${var.ssm_paths.app}" - github_app = var.github_app - tags = local.tags + source = "../ssm" + kms_key_arn = var.kms_key_arn + path_prefix = "${local.ssm_root_path}/${var.ssm_paths.app}" + github_app = var.github_app + enterprise_pat = var.enterprise_pat + tags = local.tags } diff --git a/modules/multi-runner/variables.tf b/modules/multi-runner/variables.tf index 613cf8b2ce..6d2f2920c0 100644 --- a/modules/multi-runner/variables.tf +++ b/modules/multi-runner/variables.tf @@ -5,6 +5,9 @@ variable "github_app" { If you chose to provide the configuration values directly here, please ensure the key is the base64-encoded `.pem` file (the output of `base64 app.private-key.pem`, not the content of `private-key.pem`). Note: the provided SSM parameters arn and name have a precedence over the actual value (i.e `key_base64_ssm` has a precedence over `key_base64` etc). + + For enterprise runners (runner_registration_level = "enterprise"), only `webhook_secret` (or `webhook_secret_ssm`) is required. + The `key_base64` and `id` fields are only needed for org/repo level runners. EOF type = object({ key_base64 = optional(string) @@ -25,13 +28,45 @@ variable "github_app" { }) validation { - condition = (var.github_app.key_base64 != null || var.github_app.key_base64_ssm != null) && (var.github_app.id != null || var.github_app.id_ssm != null) && (var.github_app.webhook_secret != null || var.github_app.webhook_secret_ssm != null) - error_message = < 0 && can(regex("^[a-zA-Z0-9-]+$", var.enterprise_slug))) + error_message = "Variable 'enterprise_slug' must be a non-empty string containing only alphanumeric characters and hyphens. Example: enterprise_slug = \"my-enterprise\"" + } +} + +variable "enterprise_pat" { + description = < [enable\_job\_queued\_check](#input\_enable\_job\_queued\_check) | Only scale if the job event received by the scale up lambda is is in the state queued. By default enabled for non ephemeral runners and disabled for ephemeral. Set this variable to overwrite the default behavior. | `bool` | `null` | no | | [enable\_managed\_runner\_security\_group](#input\_enable\_managed\_runner\_security\_group) | Enabling the default managed security group creation. Unmanaged security groups can be specified via `runner_additional_security_group_ids`. | `bool` | `true` | no | | [enable\_on\_demand\_failover\_for\_errors](#input\_enable\_on\_demand\_failover\_for\_errors) | Enable on-demand failover. For example to fall back to on demand when no spot capacity is available the variable can be set to `InsufficientInstanceCapacity`. When not defined the default behavior is to retry later. | `list(string)` | `[]` | no | -| [enable\_organization\_runners](#input\_enable\_organization\_runners) | Register runners to organization, instead of repo level | `bool` | n/a | yes | | [enable\_runner\_binaries\_syncer](#input\_enable\_runner\_binaries\_syncer) | Option to disable the lambda to sync GitHub runner distribution, useful when using a pre-build AMI. | `bool` | `true` | no | | [enable\_runner\_detailed\_monitoring](#input\_enable\_runner\_detailed\_monitoring) | Enable detailed monitoring for runners | `bool` | `false` | no | | [enable\_ssm\_on\_runners](#input\_enable\_ssm\_on\_runners) | Enable to allow access to the runner instances for debugging purposes via SSM. Note that this adds additional permissions to the runner instances. | `bool` | n/a | yes | | [enable\_user\_data\_debug\_logging](#input\_enable\_user\_data\_debug\_logging) | Option to enable debug logging for user-data, this logs all secrets as well. | `bool` | `false` | no | | [enable\_userdata](#input\_enable\_userdata) | Should the userdata script be enabled for the runner. Set this to false if you are using your own prebuilt AMI | `bool` | `true` | no | +| [enterprise\_pat\_parameter](#input\_enterprise\_pat\_parameter) | SSM parameter for the enterprise PAT. Object with name and arn. |
object({
name = string
arn = string
})
| `null` | no | +| [enterprise\_slug](#input\_enterprise\_slug) | The slug (URL identifier) of the GitHub Enterprise account. Required when runner\_registration\_level is "enterprise". | `string` | `null` | no | | [ghes\_ssl\_verify](#input\_ghes\_ssl\_verify) | GitHub Enterprise SSL verification. Set to 'false' when custom certificate (chains) is used for GitHub Enterprise Server (insecure). | `bool` | `true` | no | | [ghes\_url](#input\_ghes\_url) | GitHub Enterprise Server URL. DO NOT SET IF USING PUBLIC GITHUB..However if you are using GitHub Enterprise Cloud with data-residency (ghe.com), set the endpoint here. Example - https://companyname.ghe.com\| | `string` | `null` | no | | [github\_app\_parameters](#input\_github\_app\_parameters) | Parameter Store for GitHub App Parameters. |
object({
key_base64 = map(string)
id = map(string)
})
| n/a | yes | @@ -199,7 +201,7 @@ yarn run dist | [pool\_lambda\_memory\_size](#input\_pool\_lambda\_memory\_size) | Lambda Memory size limit in MB for pool lambda | `number` | `512` | no | | [pool\_lambda\_reserved\_concurrent\_executions](#input\_pool\_lambda\_reserved\_concurrent\_executions) | Amount of reserved concurrent executions for the scale-up lambda function. A value of 0 disables lambda from being triggered and -1 removes any concurrency limitations. | `number` | `1` | no | | [pool\_lambda\_timeout](#input\_pool\_lambda\_timeout) | Time out for the pool lambda in seconds. | `number` | `60` | no | -| [pool\_runner\_owner](#input\_pool\_runner\_owner) | The pool will deploy runners to the GitHub org ID, set this value to the org to which you want the runners deployed. Repo level is not supported. | `string` | `null` | no | +| [pool\_runner\_owner](#input\_pool\_runner\_owner) | The pool will deploy runners to the GitHub org ID, set this value to the org to which you want the runners deployed. Repo level is not supported. For enterprise-level runners, defaults to the enterprise\_slug if not set. | `string` | `null` | no | | [prefix](#input\_prefix) | The prefix used for naming resources | `string` | `"github-actions"` | no | | [role\_path](#input\_role\_path) | The path that will be added to the role; if not set, the prefix will be used. | `string` | `null` | no | | [role\_permissions\_boundary](#input\_role\_permissions\_boundary) | Permissions boundary that will be added to the created role for the lambda. | `string` | `null` | no | @@ -217,6 +219,7 @@ yarn run dist | [runner\_log\_files](#input\_runner\_log\_files) | (optional) List of logfiles to send to CloudWatch, will only be used if `enable_cloudwatch_agent` is set to true. Object description: `log_group_name`: Name of the log group, `prefix_log_group`: If true, the log group name will be prefixed with `/github-self-hosted-runners/`, `file_path`: path to the log file, `log_stream_name`: name of the log stream, `log_class`: The log class of the log group. Valid values are `STANDARD` or `INFREQUENT_ACCESS`. Defaults to `STANDARD`. |
list(object({
log_group_name = string
prefix_log_group = bool
file_path = string
log_stream_name = string
log_class = optional(string, "STANDARD")
}))
| `null` | no | | [runner\_name\_prefix](#input\_runner\_name\_prefix) | The prefix used for the GitHub runner name. The prefix will be used in the default start script to prefix the instance name when register the runner in GitHub. The value is available via an EC2 tag 'ghr:runner\_name\_prefix'. | `string` | `""` | no | | [runner\_os](#input\_runner\_os) | The EC2 Operating System type to use for action runner instances (linux,windows). | `string` | `"linux"` | no | +| [runner\_registration\_level](#input\_runner\_registration\_level) | The level at which runners are registered in GitHub. Valid values: "repo", "org", "enterprise". Defaults to "repo" when not set. | `string` | `null` | no | | [runner\_run\_as](#input\_runner\_run\_as) | Run the GitHub actions agent as user. | `string` | `"ec2-user"` | no | | [runners\_lambda\_s3\_key](#input\_runners\_lambda\_s3\_key) | S3 key for runners lambda function. Required if using S3 bucket to specify lambdas. | `string` | `null` | no | | [runners\_lambda\_s3\_object\_version](#input\_runners\_lambda\_s3\_object\_version) | S3 object version for runners lambda function. Useful if S3 versioning is enabled on source bucket. | `string` | `null` | no | diff --git a/modules/runners/job-retry.tf b/modules/runners/job-retry.tf index bcaec64625..46b67aad38 100644 --- a/modules/runners/job-retry.tf +++ b/modules/runners/job-retry.tf @@ -25,7 +25,9 @@ locals { zip = var.lambda_zip tracing_config = var.tracing_config github_app_parameters = var.github_app_parameters - enable_organization_runners = var.enable_organization_runners + runner_registration_level = var.runner_registration_level + enterprise_slug = var.enterprise_slug + enterprise_pat_parameter = var.enterprise_pat_parameter sqs_build_queue = var.sqs_build_queue ghes_url = var.ghes_url lambda_event_source_mapping_batch_size = var.lambda_event_source_mapping_batch_size diff --git a/modules/runners/job-retry/README.md b/modules/runners/job-retry/README.md index 7ecd69deeb..67efde46b6 100644 --- a/modules/runners/job-retry/README.md +++ b/modules/runners/job-retry/README.md @@ -42,7 +42,7 @@ The module is an inner module and used by the runner module when the opt-in feat | Name | Description | Type | Default | Required | |------|-------------|------|---------|:--------:| -| [config](#input\_config) | Configuration for the spot termination watcher lambda function.

`aws_partition`: Partition for the base arn if not 'aws'
`architecture`: AWS Lambda architecture. Lambda functions using Graviton processors ('arm64') tend to have better price/performance than 'x86\_64' functions.
`environment_variables`: Environment variables for the lambda.
`enable_organization_runners`: Enable organization runners.
`enable_metric`: Enable metric for the lambda. If `spot_warning` is set to true, the lambda will emit a metric when it detects a spot termination warning.
'ghes\_url': Optional GitHub Enterprise Server URL.
'user\_agent': Optional User-Agent header for GitHub API requests.
'github\_app\_parameters': Parameter Store for GitHub App Parameters.
'kms\_key\_arn': Optional CMK Key ARN instead of using the default AWS managed key.
`lambda_event_source_mapping_batch_size`: Maximum number of records to pass to the lambda function in a single batch for the event source mapping. When not set, the AWS default will be used.
`lambda_event_source_mapping_maximum_batching_window_in_seconds`: Maximum amount of time to gather records before invoking the lambda function, in seconds. AWS requires this to be greater than 0 if batch\_size is greater than 10.
`lambda_principals`: Add extra principals to the role created for execution of the lambda, e.g. for local testing.
`lambda_tags`: Map of tags that will be added to created resources. By default resources will be tagged with name and environment.
`log_level`: Logging level for lambda logging. Valid values are 'silly', 'trace', 'debug', 'info', 'warn', 'error', 'fatal'.
`logging_kms_key_id`: Specifies the kms key id to encrypt the logs with
`logging_retention_in_days`: Specifies the number of days you want to retain log events for the lambda log group. Possible values are: 0, 1, 3, 5, 7, 14, 30, 60, 90, 120, 150, 180, 365, 400, 545, 731, 1827, and 3653.
`memory_size`: Memory size limit in MB of the lambda.
`metrics`: Configuration to enable metrics creation by the lambda.
`prefix`: The prefix used for naming resources.
`role_path`: The path that will be added to the role, if not set the environment name will be used.
`role_permissions_boundary`: Permissions boundary that will be added to the created role for the lambda.
`runtime`: AWS Lambda runtime.
`s3_bucket`: S3 bucket from which to specify lambda functions. This is an alternative to providing local files directly.
`s3_key`: S3 key for syncer lambda function. Required if using S3 bucket to specify lambdas.
`s3_object_version`: S3 object version for syncer lambda function. Useful if S3 versioning is enabled on source bucket.
`security_group_ids`: List of security group IDs associated with the Lambda function.
'sqs\_build\_queue': SQS queue for build events to re-publish job request.
`subnet_ids`: List of subnets in which the action runners will be launched, the subnets needs to be subnets in the `vpc_id`.
`tag_filters`: Map of tags that will be used to filter the resources to be tracked. Only for which all tags are present and starting with the same value as the value in the map will be tracked.
`tags`: Map of tags that will be added to created resources. By default resources will be tagged with name and environment.
`timeout`: Time out of the lambda in seconds.
`tracing_config`: Configuration for lambda tracing.
`zip`: File location of the lambda zip file. |
object({
aws_partition = optional(string, null)
architecture = optional(string, null)
enable_organization_runners = bool
environment_variables = optional(map(string), {})
ghes_url = optional(string, null)
user_agent = optional(string, null)
github_app_parameters = object({
key_base64 = map(string)
id = map(string)
})
kms_key_arn = optional(string, null)
lambda_event_source_mapping_batch_size = optional(number, 10)
lambda_event_source_mapping_maximum_batching_window_in_seconds = optional(number, 0)
lambda_tags = optional(map(string), {})
log_level = optional(string, null)
logging_kms_key_id = optional(string, null)
logging_retention_in_days = optional(number, null)
memory_size = optional(number, null)
metrics = optional(object({
enable = optional(bool, false)
namespace = optional(string, null)
metric = optional(object({
enable_github_app_rate_limit = optional(bool, true)
enable_job_retry = optional(bool, true)
}), {})
}), {})
prefix = optional(string, null)
principals = optional(list(object({
type = string
identifiers = list(string)
})), [])
queue_encryption = optional(object({
kms_data_key_reuse_period_seconds = optional(number, null)
kms_master_key_id = optional(string, null)
sqs_managed_sse_enabled = optional(bool, true)
}), {})
role_path = optional(string, null)
role_permissions_boundary = optional(string, null)
runtime = optional(string, null)
security_group_ids = optional(list(string), [])
subnet_ids = optional(list(string), [])
s3_bucket = optional(string, null)
s3_key = optional(string, null)
s3_object_version = optional(string, null)
sqs_build_queue = object({
url = string
arn = string
})
tags = optional(map(string), {})
timeout = optional(number, 30)
tracing_config = optional(object({
mode = optional(string, null)
capture_http_requests = optional(bool, false)
capture_error = optional(bool, false)
}), {})
zip = optional(string, null)
})
| n/a | yes | +| [config](#input\_config) | Configuration for the spot termination watcher lambda function.

`aws_partition`: Partition for the base arn if not 'aws'
`architecture`: AWS Lambda architecture. Lambda functions using Graviton processors ('arm64') tend to have better price/performance than 'x86\_64' functions.
`environment_variables`: Environment variables for the lambda.
`enable_metric`: Enable metric for the lambda. If `spot_warning` is set to true, the lambda will emit a metric when it detects a spot termination warning.
'ghes\_url': Optional GitHub Enterprise Server URL.
'user\_agent': Optional User-Agent header for GitHub API requests.
'github\_app\_parameters': Parameter Store for GitHub App Parameters.
'kms\_key\_arn': Optional CMK Key ARN instead of using the default AWS managed key.
`lambda_event_source_mapping_batch_size`: Maximum number of records to pass to the lambda function in a single batch for the event source mapping. When not set, the AWS default will be used.
`lambda_event_source_mapping_maximum_batching_window_in_seconds`: Maximum amount of time to gather records before invoking the lambda function, in seconds. AWS requires this to be greater than 0 if batch\_size is greater than 10.
`lambda_principals`: Add extra principals to the role created for execution of the lambda, e.g. for local testing.
`lambda_tags`: Map of tags that will be added to created resources. By default resources will be tagged with name and environment.
`log_level`: Logging level for lambda logging. Valid values are 'silly', 'trace', 'debug', 'info', 'warn', 'error', 'fatal'.
`logging_kms_key_id`: Specifies the kms key id to encrypt the logs with
`logging_retention_in_days`: Specifies the number of days you want to retain log events for the lambda log group. Possible values are: 0, 1, 3, 5, 7, 14, 30, 60, 90, 120, 150, 180, 365, 400, 545, 731, 1827, and 3653.
`memory_size`: Memory size limit in MB of the lambda.
`metrics`: Configuration to enable metrics creation by the lambda.
`prefix`: The prefix used for naming resources.
`role_path`: The path that will be added to the role, if not set the environment name will be used.
`role_permissions_boundary`: Permissions boundary that will be added to the created role for the lambda.
`runtime`: AWS Lambda runtime.
`s3_bucket`: S3 bucket from which to specify lambda functions. This is an alternative to providing local files directly.
`s3_key`: S3 key for syncer lambda function. Required if using S3 bucket to specify lambdas.
`s3_object_version`: S3 object version for syncer lambda function. Useful if S3 versioning is enabled on source bucket.
`security_group_ids`: List of security group IDs associated with the Lambda function.
'sqs\_build\_queue': SQS queue for build events to re-publish job request.
`subnet_ids`: List of subnets in which the action runners will be launched, the subnets needs to be subnets in the `vpc_id`.
`tag_filters`: Map of tags that will be used to filter the resources to be tracked. Only for which all tags are present and starting with the same value as the value in the map will be tracked.
`tags`: Map of tags that will be added to created resources. By default resources will be tagged with name and environment.
`timeout`: Time out of the lambda in seconds.
`tracing_config`: Configuration for lambda tracing.
`zip`: File location of the lambda zip file. |
object({
aws_partition = optional(string, null)
architecture = optional(string, null)
runner_registration_level = optional(string, null)
enterprise_slug = optional(string, null)
enterprise_pat_parameter = optional(object({
name = string
arn = string
}), null)
environment_variables = optional(map(string), {})
ghes_url = optional(string, null)
user_agent = optional(string, null)
github_app_parameters = object({
key_base64 = map(string)
id = map(string)
})
kms_key_arn = optional(string, null)
lambda_event_source_mapping_batch_size = optional(number, 10)
lambda_event_source_mapping_maximum_batching_window_in_seconds = optional(number, 0)
lambda_tags = optional(map(string), {})
log_level = optional(string, null)
logging_kms_key_id = optional(string, null)
logging_retention_in_days = optional(number, null)
memory_size = optional(number, null)
metrics = optional(object({
enable = optional(bool, false)
namespace = optional(string, null)
metric = optional(object({
enable_github_app_rate_limit = optional(bool, true)
enable_job_retry = optional(bool, true)
}), {})
}), {})
prefix = optional(string, null)
principals = optional(list(object({
type = string
identifiers = list(string)
})), [])
queue_encryption = optional(object({
kms_data_key_reuse_period_seconds = optional(number, null)
kms_master_key_id = optional(string, null)
sqs_managed_sse_enabled = optional(bool, true)
}), {})
role_path = optional(string, null)
role_permissions_boundary = optional(string, null)
runtime = optional(string, null)
security_group_ids = optional(list(string), [])
subnet_ids = optional(list(string), [])
s3_bucket = optional(string, null)
s3_key = optional(string, null)
s3_object_version = optional(string, null)
sqs_build_queue = object({
url = string
arn = string
})
tags = optional(map(string), {})
timeout = optional(number, 30)
tracing_config = optional(object({
mode = optional(string, null)
capture_http_requests = optional(bool, false)
capture_error = optional(bool, false)
}), {})
zip = optional(string, null)
})
| n/a | yes | ## Outputs diff --git a/modules/runners/job-retry/main.tf b/modules/runners/job-retry/main.tf index eba478b214..533f627ae2 100644 --- a/modules/runners/job-retry/main.tf +++ b/modules/runners/job-retry/main.tf @@ -3,7 +3,8 @@ locals { name = "job-retry" environment_variables = { - ENABLE_ORGANIZATION_RUNNERS = var.config.enable_organization_runners + RUNNER_REGISTRATION_LEVEL = var.config.runner_registration_level != null ? var.config.runner_registration_level : "" + ENTERPRISE_SLUG = var.config.enterprise_slug != null ? var.config.enterprise_slug : "" ENABLE_METRIC_JOB_RETRY = var.config.metrics.enable && var.config.metrics.metric.enable_job_retry ENABLE_METRIC_GITHUB_APP_RATE_LIMIT = var.config.metrics.enable && var.config.metrics.metric.enable_github_app_rate_limit GHES_URL = var.config.ghes_url @@ -11,6 +12,7 @@ locals { JOB_QUEUE_SCALE_UP_URL = var.config.sqs_build_queue.url PARAMETER_GITHUB_APP_ID_NAME = var.config.github_app_parameters.id.name PARAMETER_GITHUB_APP_KEY_BASE64_NAME = var.config.github_app_parameters.key_base64.name + PARAMETER_ENTERPRISE_PAT_NAME = try(var.config.enterprise_pat_parameter.name, "") } config = merge(var.config, { @@ -62,11 +64,14 @@ resource "aws_iam_role_policy" "job_retry" { name = "job_retry-policy" role = module.job_retry.lambda.role.name policy = templatefile("${path.module}/policies/lambda.json", { - kms_key_arn = var.config.kms_key_arn != null ? var.config.kms_key_arn : "" - sqs_build_queue_arn = var.config.sqs_build_queue.arn - sqs_job_retry_queue_arn = aws_sqs_queue.job_retry_check_queue.arn - github_app_id_arn = var.config.github_app_parameters.id.arn - github_app_key_base64_arn = var.config.github_app_parameters.key_base64.arn + kms_key_arn = var.config.kms_key_arn != null ? var.config.kms_key_arn : "" + sqs_build_queue_arn = var.config.sqs_build_queue.arn + sqs_job_retry_queue_arn = aws_sqs_queue.job_retry_check_queue.arn + ssm_parameter_arns = compact([ + var.config.github_app_parameters.id.arn, + var.config.github_app_parameters.key_base64.arn, + try(var.config.enterprise_pat_parameter.arn, ""), + ]) }) } diff --git a/modules/runners/job-retry/policies/lambda.json b/modules/runners/job-retry/policies/lambda.json index f1c9efd569..086c88d019 100644 --- a/modules/runners/job-retry/policies/lambda.json +++ b/modules/runners/job-retry/policies/lambda.json @@ -1,17 +1,16 @@ { "Version": "2012-10-17", "Statement": [ +%{ if length(ssm_parameter_arns) > 0 ~} { "Effect": "Allow", "Action": [ "ssm:GetParameter", "ssm:GetParameters" ], - "Resource": [ - "${github_app_key_base64_arn}", - "${github_app_id_arn}" - ] + "Resource": ${jsonencode(ssm_parameter_arns)} }, +%{ endif ~} { "Effect": "Allow", "Action": [ diff --git a/modules/runners/job-retry/variables.tf b/modules/runners/job-retry/variables.tf index 7ccfdf63b3..25a70db764 100644 --- a/modules/runners/job-retry/variables.tf +++ b/modules/runners/job-retry/variables.tf @@ -5,7 +5,6 @@ variable "config" { `aws_partition`: Partition for the base arn if not 'aws' `architecture`: AWS Lambda architecture. Lambda functions using Graviton processors ('arm64') tend to have better price/performance than 'x86_64' functions. `environment_variables`: Environment variables for the lambda. - `enable_organization_runners`: Enable organization runners. `enable_metric`: Enable metric for the lambda. If `spot_warning` is set to true, the lambda will emit a metric when it detects a spot termination warning. 'ghes_url': Optional GitHub Enterprise Server URL. 'user_agent': Optional User-Agent header for GitHub API requests. @@ -37,12 +36,17 @@ variable "config" { `zip`: File location of the lambda zip file. EOF type = object({ - aws_partition = optional(string, null) - architecture = optional(string, null) - enable_organization_runners = bool - environment_variables = optional(map(string), {}) - ghes_url = optional(string, null) - user_agent = optional(string, null) + aws_partition = optional(string, null) + architecture = optional(string, null) + runner_registration_level = optional(string, null) + enterprise_slug = optional(string, null) + enterprise_pat_parameter = optional(object({ + name = string + arn = string + }), null) + environment_variables = optional(map(string), {}) + ghes_url = optional(string, null) + user_agent = optional(string, null) github_app_parameters = object({ key_base64 = map(string) id = map(string) diff --git a/modules/runners/policies/lambda-scale-down.json b/modules/runners/policies/lambda-scale-down.json index 067a747c81..4087b255a1 100644 --- a/modules/runners/policies/lambda-scale-down.json +++ b/modules/runners/policies/lambda-scale-down.json @@ -50,8 +50,16 @@ "ssm:GetParameters" ], "Resource": [ - "${github_app_key_base64_arn}", - "${github_app_id_arn}" + "${ssm_config_path}/*" +%{ if github_app_key_base64_arn != "" ~} + ,"${github_app_key_base64_arn}" +%{ endif ~} +%{ if github_app_id_arn != "" ~} + ,"${github_app_id_arn}" +%{ endif ~} +%{ if enterprise_pat_arn != "" ~} + ,"${enterprise_pat_arn}" +%{ endif ~} ] %{ if kms_key_arn != "" ~} }, diff --git a/modules/runners/policies/lambda-scale-up.json b/modules/runners/policies/lambda-scale-up.json index 93faf506a3..7d68fa3b81 100644 --- a/modules/runners/policies/lambda-scale-up.json +++ b/modules/runners/policies/lambda-scale-up.json @@ -34,10 +34,17 @@ "ssm:GetParameters" ], "Resource": [ - "${github_app_key_base64_arn}", - "${github_app_id_arn}", "${ssm_config_path}/*", "${ssm_ami_id_parameter_arn}" +%{ if github_app_key_base64_arn != "" ~} + ,"${github_app_key_base64_arn}" +%{ endif ~} +%{ if github_app_id_arn != "" ~} + ,"${github_app_id_arn}" +%{ endif ~} +%{ if enterprise_pat_arn != "" ~} + ,"${enterprise_pat_arn}" +%{ endif ~} ] }, { diff --git a/modules/runners/pool.tf b/modules/runners/pool.tf index 53c5d1c2cd..f4094f2043 100644 --- a/modules/runners/pool.tf +++ b/modules/runners/pool.tf @@ -61,6 +61,9 @@ module "pool" { tags = local.tags lambda_tags = var.lambda_tags arn_ssm_parameters_path_config = local.arn_ssm_parameters_path_config + runner_registration_level = var.runner_registration_level + enterprise_slug = var.enterprise_slug + enterprise_pat_parameter = var.enterprise_pat_parameter } aws_partition = var.aws_partition diff --git a/modules/runners/pool/README.md b/modules/runners/pool/README.md index a09538aced..0f9e7430f2 100644 --- a/modules/runners/pool/README.md +++ b/modules/runners/pool/README.md @@ -49,7 +49,7 @@ No modules. | Name | Description | Type | Default | Required | |------|-------------|------|---------|:--------:| | [aws\_partition](#input\_aws\_partition) | (optional) partition for the arn if not 'aws' | `string` | `"aws"` | no | -| [config](#input\_config) | Lookup details in parent module. |
object({
lambda = object({
log_level = string
logging_retention_in_days = number
logging_kms_key_id = string
log_class = string
reserved_concurrent_executions = number
s3_bucket = string
s3_key = string
s3_object_version = string
security_group_ids = list(string)
runtime = string
architecture = string
memory_size = number
timeout = number
zip = string
subnet_ids = list(string)
parameter_store_tags = string
})
tags = map(string)
ghes = object({
url = string
ssl_verify = string
})
github_app_parameters = object({
key_base64 = map(string)
id = map(string)
})
subnet_ids = list(string)
runner = object({
disable_runner_autoupdate = bool
ephemeral = bool
enable_jit_config = bool
enable_on_demand_failover_for_errors = list(string)
scale_errors = list(string)
boot_time_in_minutes = number
labels = list(string)
launch_template = object({
name = string
})
group_name = string
name_prefix = string
pool_owner = string
role = object({
arn = string
})
})
instance_types = list(string)
instance_target_capacity_type = string
instance_allocation_strategy = string
instance_max_spot_price = string
prefix = string
pool = list(object({
schedule_expression = string
schedule_expression_timezone = string
size = number
}))
role_permissions_boundary = string
kms_key_arn = string
ami_kms_key_arn = string
ami_id_ssm_parameter_arn = string
role_path = string
ssm_token_path = string
ssm_config_path = string
ami_id_ssm_parameter_name = string
ami_id_ssm_parameter_read_policy_arn = string
arn_ssm_parameters_path_config = string
lambda_tags = map(string)
user_agent = string
})
| n/a | yes | +| [config](#input\_config) | Lookup details in parent module. |
object({
lambda = object({
log_level = string
logging_retention_in_days = number
logging_kms_key_id = string
log_class = string
reserved_concurrent_executions = number
s3_bucket = string
s3_key = string
s3_object_version = string
security_group_ids = list(string)
runtime = string
architecture = string
memory_size = number
timeout = number
zip = string
subnet_ids = list(string)
parameter_store_tags = string
})
tags = map(string)
ghes = object({
url = string
ssl_verify = string
})
github_app_parameters = object({
key_base64 = map(string)
id = map(string)
})
subnet_ids = list(string)
runner = object({
disable_runner_autoupdate = bool
ephemeral = bool
enable_jit_config = bool
enable_on_demand_failover_for_errors = list(string)
scale_errors = list(string)
boot_time_in_minutes = number
labels = list(string)
launch_template = object({
name = string
})
group_name = string
name_prefix = string
pool_owner = optional(string, null)
role = object({
arn = string
})
})
instance_types = list(string)
instance_target_capacity_type = string
instance_allocation_strategy = string
instance_max_spot_price = string
prefix = string
pool = list(object({
schedule_expression = string
schedule_expression_timezone = string
size = number
}))
role_permissions_boundary = string
kms_key_arn = string
ami_kms_key_arn = string
ami_id_ssm_parameter_arn = string
role_path = string
ssm_token_path = string
ssm_config_path = string
ami_id_ssm_parameter_name = string
ami_id_ssm_parameter_read_policy_arn = string
arn_ssm_parameters_path_config = string
lambda_tags = map(string)
user_agent = string
runner_registration_level = optional(string, null)
enterprise_slug = optional(string, null)
enterprise_pat_parameter = optional(object({
name = string
arn = string
}), null)
})
| n/a | yes | | [tracing\_config](#input\_tracing\_config) | Configuration for lambda tracing. |
object({
mode = optional(string, null)
capture_http_requests = optional(bool, false)
capture_error = optional(bool, false)
})
| `{}` | no | ## Outputs diff --git a/modules/runners/pool/main.tf b/modules/runners/pool/main.tf index 5363f3c3fb..75eba38ca4 100644 --- a/modules/runners/pool/main.tf +++ b/modules/runners/pool/main.tf @@ -38,7 +38,10 @@ resource "aws_lambda_function" "pool" { RUNNER_LABELS = lower(join(",", var.config.runner.labels)) RUNNER_GROUP_NAME = var.config.runner.group_name RUNNER_NAME_PREFIX = var.config.runner.name_prefix - RUNNER_OWNER = var.config.runner.pool_owner + RUNNER_OWNER = coalesce(var.config.runner.pool_owner, var.config.enterprise_slug, "") + RUNNER_REGISTRATION_LEVEL = var.config.runner_registration_level != null ? var.config.runner_registration_level : "" + ENTERPRISE_SLUG = var.config.enterprise_slug != null ? var.config.enterprise_slug : "" + PARAMETER_ENTERPRISE_PAT_NAME = try(var.config.enterprise_pat_parameter.name, "") SSM_TOKEN_PATH = var.config.ssm_token_path SSM_CONFIG_PATH = var.config.ssm_config_path SUBNET_IDS = join(",", var.config.subnet_ids) @@ -95,6 +98,7 @@ resource "aws_iam_role_policy" "pool" { kms_key_arn = var.config.kms_key_arn ami_kms_key_arn = var.config.ami_kms_key_arn ssm_ami_id_parameter_arn = var.config.ami_id_ssm_parameter_arn + enterprise_pat_arn = try(var.config.enterprise_pat_parameter.arn, "") }) } diff --git a/modules/runners/pool/policies/lambda-pool.json b/modules/runners/pool/policies/lambda-pool.json index 91c9997ce4..fb1c5b7008 100644 --- a/modules/runners/pool/policies/lambda-pool.json +++ b/modules/runners/pool/policies/lambda-pool.json @@ -55,8 +55,16 @@ "ssm:GetParameters" ], "Resource": [ - "${github_app_key_base64_arn}", - "${github_app_id_arn}" + "${arn_ssm_parameters_path_config}/*" +%{ if github_app_key_base64_arn != "" ~} + ,"${github_app_key_base64_arn}" +%{ endif ~} +%{ if github_app_id_arn != "" ~} + ,"${github_app_id_arn}" +%{ endif ~} +%{ if enterprise_pat_arn != "" ~} + ,"${enterprise_pat_arn}" +%{ endif ~} ] %{ if kms_key_arn != "" ~} }, diff --git a/modules/runners/pool/variables.tf b/modules/runners/pool/variables.tf index 4bfdd68010..b173844990 100644 --- a/modules/runners/pool/variables.tf +++ b/modules/runners/pool/variables.tf @@ -42,7 +42,7 @@ variable "config" { }) group_name = string name_prefix = string - pool_owner = string + pool_owner = optional(string, null) role = object({ arn = string }) @@ -69,6 +69,12 @@ variable "config" { arn_ssm_parameters_path_config = string lambda_tags = map(string) user_agent = string + runner_registration_level = optional(string, null) + enterprise_slug = optional(string, null) + enterprise_pat_parameter = optional(object({ + name = string + arn = string + }), null) }) } diff --git a/modules/runners/runner-config.tf b/modules/runners/runner-config.tf index d381b7679c..9f111a018b 100644 --- a/modules/runners/runner-config.tf +++ b/modules/runners/runner-config.tf @@ -39,3 +39,11 @@ resource "aws_ssm_parameter" "token_path" { value = "${var.ssm_paths.root}/${var.ssm_paths.tokens}" tags = local.tags } + +resource "aws_ssm_parameter" "runner_registration_level" { + name = "${var.ssm_paths.root}/${var.ssm_paths.config}/runner_registration_level" + type = "String" + value = var.runner_registration_level != null ? var.runner_registration_level : "repo" + tags = local.tags +} + diff --git a/modules/runners/scale-down.tf b/modules/runners/scale-down.tf index b304e8066e..5f0b5b3ee9 100644 --- a/modules/runners/scale-down.tf +++ b/modules/runners/scale-down.tf @@ -31,6 +31,7 @@ resource "aws_lambda_function" "scale_down" { NODE_TLS_REJECT_UNAUTHORIZED = var.ghes_url != null && !var.ghes_ssl_verify ? 0 : 1 PARAMETER_GITHUB_APP_ID_NAME = var.github_app_parameters.id.name PARAMETER_GITHUB_APP_KEY_BASE64_NAME = var.github_app_parameters.key_base64.name + PARAMETER_ENTERPRISE_PAT_NAME = var.enterprise_pat_parameter != null ? var.enterprise_pat_parameter.name : "" POWERTOOLS_LOGGER_LOG_EVENT = var.log_level == "debug" ? "true" : "false" RUNNER_BOOT_TIME_IN_MINUTES = var.runner_boot_time_in_minutes SCALE_DOWN_CONFIG = jsonencode(var.idle_config) @@ -101,6 +102,8 @@ resource "aws_iam_role_policy" "scale_down" { github_app_id_arn = var.github_app_parameters.id.arn github_app_key_base64_arn = var.github_app_parameters.key_base64.arn kms_key_arn = local.kms_key_arn + enterprise_pat_arn = var.enterprise_pat_parameter != null ? var.enterprise_pat_parameter.arn : "" + ssm_config_path = "arn:${var.aws_partition}:ssm:${var.aws_region}:${data.aws_caller_identity.current.account_id}:parameter${var.ssm_paths.root}/${var.ssm_paths.config}" }) } diff --git a/modules/runners/scale-up.tf b/modules/runners/scale-up.tf index c5503f6394..8e7b12bfdc 100644 --- a/modules/runners/scale-up.tf +++ b/modules/runners/scale-up.tf @@ -31,7 +31,9 @@ resource "aws_lambda_function" "scale_up" { ENABLE_JIT_CONFIG = var.enable_jit_config ENABLE_JOB_QUEUED_CHECK = local.enable_job_queued_check ENABLE_METRIC_GITHUB_APP_RATE_LIMIT = var.metrics.enable && var.metrics.metric.enable_github_app_rate_limit - ENABLE_ORGANIZATION_RUNNERS = var.enable_organization_runners + RUNNER_REGISTRATION_LEVEL = var.runner_registration_level != null ? var.runner_registration_level : "" + ENTERPRISE_SLUG = var.enterprise_slug != null ? var.enterprise_slug : "" + PARAMETER_ENTERPRISE_PAT_NAME = var.enterprise_pat_parameter != null ? var.enterprise_pat_parameter.name : "" ENVIRONMENT = var.prefix GHES_URL = var.ghes_url USER_AGENT = var.user_agent @@ -126,6 +128,7 @@ resource "aws_iam_role_policy" "scale_up" { kms_key_arn = local.kms_key_arn ami_kms_key_arn = local.ami_kms_key_arn ssm_ami_id_parameter_arn = local.ami_id_ssm_module_managed ? aws_ssm_parameter.runner_ami_id[0].arn : var.ami.id_ssm_parameter_arn + enterprise_pat_arn = var.enterprise_pat_parameter != null ? var.enterprise_pat_parameter.arn : "" }) } diff --git a/modules/runners/variables.tf b/modules/runners/variables.tf index e2a33280b9..26e9a7d782 100644 --- a/modules/runners/variables.tf +++ b/modules/runners/variables.tf @@ -186,9 +186,30 @@ variable "sqs_build_queue" { }) } -variable "enable_organization_runners" { - description = "Register runners to organization, instead of repo level" - type = bool +variable "runner_registration_level" { + description = "The level at which runners are registered in GitHub. Valid values: \"repo\", \"org\", \"enterprise\". Defaults to \"repo\" when not set." + type = string + default = null + + validation { + condition = var.runner_registration_level == null || contains(["repo", "org", "enterprise"], var.runner_registration_level) + error_message = "Variable 'runner_registration_level' must be one of: \"repo\", \"org\", \"enterprise\"." + } +} + +variable "enterprise_slug" { + description = "The slug (URL identifier) of the GitHub Enterprise account. Required when runner_registration_level is \"enterprise\"." + type = string + default = null +} + +variable "enterprise_pat_parameter" { + description = "SSM parameter for the enterprise PAT. Object with name and arn." + type = object({ + name = string + arn = string + }) + default = null } variable "github_app_parameters" { @@ -551,7 +572,7 @@ variable "pool_lambda_memory_size" { } variable "pool_runner_owner" { - description = "The pool will deploy runners to the GitHub org ID, set this value to the org to which you want the runners deployed. Repo level is not supported." + description = "The pool will deploy runners to the GitHub org ID, set this value to the org to which you want the runners deployed. Repo level is not supported. For enterprise-level runners, defaults to the enterprise_slug if not set." type = string default = null } diff --git a/modules/ssm/README.md b/modules/ssm/README.md index 19b872e919..14e23a843e 100644 --- a/modules/ssm/README.md +++ b/modules/ssm/README.md @@ -26,6 +26,7 @@ No modules. | Name | Type | |------|------| +| [aws_ssm_parameter.enterprise_pat](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/ssm_parameter) | resource | | [aws_ssm_parameter.github_app_id](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/ssm_parameter) | resource | | [aws_ssm_parameter.github_app_key_base64](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/ssm_parameter) | resource | | [aws_ssm_parameter.github_app_webhook_secret](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/ssm_parameter) | resource | @@ -34,7 +35,8 @@ No modules. | Name | Description | Type | Default | Required | |------|-------------|------|---------|:--------:| -| [github\_app](#input\_github\_app) | GitHub app parameters, see your github app.
You can optionally create the SSM parameters yourself and provide the ARN and name here, through the `*_ssm` attributes.
If you chose to provide the configuration values directly here,
please ensure the key is the base64-encoded `.pem` file (the output of `base64 app.private-key.pem`, not the content of `private-key.pem`).
Note: the provided SSM parameters arn and name have a precedence over the actual value (i.e `key_base64_ssm` has a precedence over `key_base64` etc). |
object({
key_base64 = optional(string)
key_base64_ssm = optional(object({
arn = string
name = string
}))
id = optional(string)
id_ssm = optional(object({
arn = string
name = string
}))
webhook_secret = optional(string)
webhook_secret_ssm = optional(object({
arn = string
name = string
}))
})
| n/a | yes | +| [enterprise\_pat](#input\_enterprise\_pat) | Enterprise Personal Access Token(s) (PAT) for authenticating with GitHub Enterprise runner management APIs.
You can either provide the PAT value directly (Terraform creates the SSM parameter) or reference a pre-existing SSM parameter.
Note: the provided SSM parameter arn and name take precedence over the direct value.

To distribute API calls across multiple PATs and avoid rate limiting, provide a comma-separated list of PATs
in the 'pat' field or in the SSM parameter value. The Lambda functions will randomly select one PAT per invocation. |
object({
pat = optional(string)
pat_ssm = optional(object({
arn = string
name = string
}))
})
| `null` | no | +| [github\_app](#input\_github\_app) | GitHub app parameters, see your github app.
You can optionally create the SSM parameters yourself and provide the ARN and name here, through the `*_ssm` attributes.
If you chose to provide the configuration values directly here,
please ensure the key is the base64-encoded `.pem` file (the output of `base64 app.private-key.pem`, not the content of `private-key.pem`).
Note: the provided SSM parameters arn and name have a precedence over the actual value (i.e `key_base64_ssm` has a precedence over `key_base64` etc).

For enterprise runners, only `webhook_secret` (or `webhook_secret_ssm`) is required.
The `key_base64` and `id` fields are only needed for org/repo level runners. |
object({
key_base64 = optional(string)
key_base64_ssm = optional(object({
arn = string
name = string
}))
id = optional(string)
id_ssm = optional(object({
arn = string
name = string
}))
webhook_secret = optional(string)
webhook_secret_ssm = optional(object({
arn = string
name = string
}))
})
| n/a | yes | | [kms\_key\_arn](#input\_kms\_key\_arn) | Optional CMK Key ARN to be used for Parameter Store. | `string` | `null` | no | | [path\_prefix](#input\_path\_prefix) | The path prefix used for naming resources | `string` | n/a | yes | | [tags](#input\_tags) | Map of tags that will be added to created resources. By default resources will be tagged with name and environment. | `map(string)` | `{}` | no | diff --git a/modules/ssm/outputs.tf b/modules/ssm/outputs.tf index 4017f6ab3d..320339c242 100644 --- a/modules/ssm/outputs.tf +++ b/modules/ssm/outputs.tf @@ -1,16 +1,26 @@ output "parameters" { value = { - github_app_id = { - name = var.github_app.id_ssm != null ? var.github_app.id_ssm.name : aws_ssm_parameter.github_app_id[0].name - arn = var.github_app.id_ssm != null ? var.github_app.id_ssm.arn : aws_ssm_parameter.github_app_id[0].arn - } - github_app_key_base64 = { - name = var.github_app.key_base64_ssm != null ? var.github_app.key_base64_ssm.name : aws_ssm_parameter.github_app_key_base64[0].name - arn = var.github_app.key_base64_ssm != null ? var.github_app.key_base64_ssm.arn : aws_ssm_parameter.github_app_key_base64[0].arn - } + github_app_id = var.github_app.id_ssm != null ? { + name = var.github_app.id_ssm.name + arn = var.github_app.id_ssm.arn + } : length(aws_ssm_parameter.github_app_id) > 0 ? { + name = aws_ssm_parameter.github_app_id[0].name + arn = aws_ssm_parameter.github_app_id[0].arn + } : null + github_app_key_base64 = var.github_app.key_base64_ssm != null ? { + name = var.github_app.key_base64_ssm.name + arn = var.github_app.key_base64_ssm.arn + } : length(aws_ssm_parameter.github_app_key_base64) > 0 ? { + name = aws_ssm_parameter.github_app_key_base64[0].name + arn = aws_ssm_parameter.github_app_key_base64[0].arn + } : null github_app_webhook_secret = { name = var.github_app.webhook_secret_ssm != null ? var.github_app.webhook_secret_ssm.name : aws_ssm_parameter.github_app_webhook_secret[0].name arn = var.github_app.webhook_secret_ssm != null ? var.github_app.webhook_secret_ssm.arn : aws_ssm_parameter.github_app_webhook_secret[0].arn } + enterprise_pat = var.enterprise_pat != null ? { + name = var.enterprise_pat.pat_ssm != null ? var.enterprise_pat.pat_ssm.name : aws_ssm_parameter.enterprise_pat[0].name + arn = var.enterprise_pat.pat_ssm != null ? var.enterprise_pat.pat_ssm.arn : aws_ssm_parameter.enterprise_pat[0].arn + } : null } } diff --git a/modules/ssm/ssm.tf b/modules/ssm/ssm.tf index 3f13333e68..0ff0f47d60 100644 --- a/modules/ssm/ssm.tf +++ b/modules/ssm/ssm.tf @@ -1,5 +1,5 @@ resource "aws_ssm_parameter" "github_app_id" { - count = var.github_app.id_ssm != null ? 0 : 1 + count = var.github_app.id != null && var.github_app.id_ssm == null ? 1 : 0 name = "${var.path_prefix}/github_app_id" type = "SecureString" value = var.github_app.id @@ -8,7 +8,7 @@ resource "aws_ssm_parameter" "github_app_id" { } resource "aws_ssm_parameter" "github_app_key_base64" { - count = var.github_app.key_base64_ssm != null ? 0 : 1 + count = var.github_app.key_base64 != null && var.github_app.key_base64_ssm == null ? 1 : 0 name = "${var.path_prefix}/github_app_key_base64" type = "SecureString" value = var.github_app.key_base64 @@ -24,3 +24,13 @@ resource "aws_ssm_parameter" "github_app_webhook_secret" { key_id = local.kms_key_arn tags = var.tags } + +resource "aws_ssm_parameter" "enterprise_pat" { + count = var.enterprise_pat != null && var.enterprise_pat.pat != null && var.enterprise_pat.pat_ssm == null ? 1 : 0 + name = "${var.path_prefix}/enterprise_pat" + type = "SecureString" + value = var.enterprise_pat.pat + key_id = local.kms_key_arn + tags = var.tags +} + diff --git a/modules/ssm/variables.tf b/modules/ssm/variables.tf index 1eb796aea7..ee5bb33d61 100644 --- a/modules/ssm/variables.tf +++ b/modules/ssm/variables.tf @@ -1,10 +1,13 @@ variable "github_app" { description = < 0 && can(regex("^[a-zA-Z0-9-]+$", var.enterprise_slug))) + error_message = "Variable 'enterprise_slug' must be a non-empty string containing only alphanumeric characters and hyphens. Example: enterprise_slug = \"my-enterprise\"" + } +} + +variable "enterprise_pat" { + description = <