diff --git a/README.md b/README.md index b17dfc3b86..f097d3ebd2 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,7 @@ Join our discord community via [this invite link](https://discord.gg/bxgXW8jJGh) ## Requirements | Name | Version | -| ---- | ------- | +|------|---------| | [terraform](#requirement\_terraform) | >= 1.3.0 | | [aws](#requirement\_aws) | >= 6.33 | | [random](#requirement\_random) | ~> 3.0 | @@ -74,14 +74,14 @@ Join our discord community via [this invite link](https://discord.gg/bxgXW8jJGh) ## Providers | Name | Version | -| ---- | ------- | -| [aws](#provider\_aws) | 6.60.0 | -| [random](#provider\_random) | 3.9.0 | +|------|---------| +| [aws](#provider\_aws) | >= 6.33 | +| [random](#provider\_random) | ~> 3.0 | ## Modules | Name | Source | Version | -| ---- | ------ | ------- | +|------|--------|---------| | [ami\_housekeeper](#module\_ami\_housekeeper) | ./modules/ami-housekeeper | n/a | | [instance\_termination\_watcher](#module\_instance\_termination\_watcher) | ./modules/termination-watcher | n/a | | [runner\_binaries](#module\_runner\_binaries) | ./modules/runner-binaries-syncer | n/a | @@ -92,7 +92,7 @@ Join our discord community via [this invite link](https://discord.gg/bxgXW8jJGh) ## Resources | Name | Type | -| ---- | ---- | +|------|------| | [aws_sqs_queue.queued_builds](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/sqs_queue) | resource | | [aws_sqs_queue.queued_builds_dlq](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/sqs_queue) | resource | | [aws_sqs_queue_policy.build_queue_dlq_policy](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/sqs_queue_policy) | resource | @@ -103,7 +103,7 @@ Join our discord community via [this invite link](https://discord.gg/bxgXW8jJGh) ## Inputs | Name | Description | Type | Default | Required | -| ---- | ----------- | ---- | ------- | :------: | +|------|-------------|------|---------|:--------:| | [additional\_github\_apps](#input\_additional\_github\_apps) | Additional GitHub Apps for distributing API rate limit usage. Each must be installed on the same repos/orgs as the primary app. |
list(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 }))
installation_id = optional(string)
installation_id_ssm = optional(object({ arn = string, name = string }))
})) | `[]` | no |
| [ami](#input\_ami) | AMI configuration for the action runner instances. This object allows you to specify all AMI-related settings in one place.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` | no |
| [ami\_housekeeper\_cleanup\_config](#input\_ami\_housekeeper\_cleanup\_config) | Configuration for AMI cleanup.object({
amiFilters = optional(list(object({
Name = string
Values = list(string)
})),
[{
Name : "state",
Values : ["available"],
},
{
Name : "image-type",
Values : ["machine"],
}]
)
dryRun = optional(bool, false)
launchTemplateNames = optional(list(string))
maxItems = optional(number)
minimumDaysOld = optional(number, 30)
ssmParameterNames = optional(list(string))
}) | `{}` | no |
@@ -250,7 +250,7 @@ Join our discord community via [this invite link](https://discord.gg/bxgXW8jJGh)
## Outputs
| Name | Description |
-| ---- | ----------- |
+|------|-------------|
| [binaries\_syncer](#output\_binaries\_syncer) | n/a |
| [instance\_termination\_handler](#output\_instance\_termination\_handler) | n/a |
| [instance\_termination\_watcher](#output\_instance\_termination\_watcher) | n/a |
diff --git a/docs/rate-limits-and-tuning.md b/docs/rate-limits-and-tuning.md
index 571fd96ff1..88c74bf6d5 100644
--- a/docs/rate-limits-and-tuning.md
+++ b/docs/rate-limits-and-tuning.md
@@ -52,7 +52,7 @@ Without a token cache, each runner also costs a `POST /app/installations/{id}/ac
### Distributing load across multiple GitHub Apps
-Rate limits are per App installation and cannot be raised. To scale beyond one App's budget, configure extra Apps with `additional_github_apps`. The control-plane lambdas select one App per invocation, making the effective limit N × the per-App limit.
+Rate limits are per App installation and cannot be raised. To scale beyond one App's budget, configure extra Apps with `additional_github_apps`. The control-plane lambdas select one App per invocation, making the effective limit N × the per-App limit. Selection prefers the App with the most rate-limit budget remaining, based on the `x-ratelimit-remaining` headers observed by the running Lambda container; Apps that hit a secondary rate limit are skipped for 60 seconds.
> [!IMPORTANT]
> Every additional App must be installed on the same organizations or repositories as the primary App. The module cannot verify this. A missing installation surfaces at runtime as installation lookup 404s on the fraction of invocations that select the misconfigured App, which is hard to trace back to the installation.
diff --git a/lambdas/functions/control-plane/src/github/auth.test.ts b/lambdas/functions/control-plane/src/github/auth.test.ts
index c2524503b7..04864be8f2 100644
--- a/lambdas/functions/control-plane/src/github/auth.test.ts
+++ b/lambdas/functions/control-plane/src/github/auth.test.ts
@@ -12,6 +12,8 @@ import {
getStoredInstallationId,
onRateLimit,
onSecondaryRateLimit,
+ reportAppRateLimit,
+ reportAppSecondaryRateLimit,
resetAppCredentialsCache,
} from './auth';
import { describe, it, expect, beforeEach, vi } from 'vitest';
@@ -435,3 +437,82 @@ describe('Test getStoredInstallationId', () => {
expect(result1).toBe(67890);
});
});
+
+describe('Test rate-limit aware app selection', () => {
+ const decryptedValue = 'decryptedValue';
+ const b64 = Buffer.from(decryptedValue, 'binary').toString('base64');
+ const app2IdParam = `/actions-runner/${ENVIRONMENT}/additional_github_app_0_id`;
+ const app2KeyParam = `/actions-runner/${ENVIRONMENT}/additional_github_app_0_key_base64`;
+
+ beforeEach(() => {
+ const mockedAuth = vi.fn();
+ mockedAuth.mockResolvedValue({ token: 'token' });
+ const mockWithHook = Object.assign(mockedAuth, { hook: vi.fn() });
+ vi.mocked(createAppAuth).mockReturnValue(mockWithHook);
+
+ process.env.PARAMETER_GITHUB_APPS_MANIFEST_NAME = `/actions-runner/${ENVIRONMENT}/additional_github_apps_manifest`;
+ mockedGetParameter.mockResolvedValue(JSON.stringify([{ idParamName: app2IdParam, keyParamName: app2KeyParam }]));
+ mockedGetParameters.mockResolvedValue(
+ new Map([
+ [PARAMETER_GITHUB_APP_ID_NAME, GITHUB_APP_ID],
+ [PARAMETER_GITHUB_APP_KEY_BASE64_NAME, b64],
+ [app2IdParam, '2'],
+ [app2KeyParam, b64],
+ ]),
+ );
+
+ // Pin the random start offset to 0 so selection is deterministic.
+ vi.spyOn(Math, 'random').mockReturnValue(0);
+ });
+
+ it('selects the app with the most rate limit budget remaining', async () => {
+ reportAppRateLimit(0, 100);
+ reportAppRateLimit(1, 5000);
+
+ const result = await createGithubAppAuth(undefined);
+ expect(result.appIndex).toBe(1);
+ });
+
+ it('assumes full budget for apps without observed state', async () => {
+ reportAppRateLimit(0, 100);
+ // App 1 has no observed state and is assumed full.
+
+ const result = await createGithubAppAuth(undefined);
+ expect(result.appIndex).toBe(1);
+ });
+
+ it('skips an app cooling down after a secondary rate limit', async () => {
+ reportAppRateLimit(0, 100);
+ reportAppRateLimit(1, 5000);
+ reportAppSecondaryRateLimit(1);
+
+ const result = await createGithubAppAuth(undefined);
+ expect(result.appIndex).toBe(0);
+ });
+
+ it('falls back to the most budget when every app is cooling down', async () => {
+ reportAppRateLimit(0, 100);
+ reportAppRateLimit(1, 5000);
+ reportAppSecondaryRateLimit(0);
+ reportAppSecondaryRateLimit(1);
+
+ const result = await createGithubAppAuth(undefined);
+ expect(result.appIndex).toBe(1);
+ });
+
+ it('short-circuits to the primary app in single-app deployments', async () => {
+ delete process.env.PARAMETER_GITHUB_APPS_MANIFEST_NAME;
+ reportAppRateLimit(0, 0);
+
+ const result = await createGithubAppAuth(undefined);
+ expect(result.appIndex).toBe(0);
+ });
+
+ it('respects an explicitly provided appIndex', async () => {
+ reportAppRateLimit(0, 5000);
+ reportAppRateLimit(1, 100);
+
+ const result = await createGithubAppAuth(undefined, '', 1);
+ expect(result.appIndex).toBe(1);
+ });
+});
diff --git a/lambdas/functions/control-plane/src/github/auth.ts b/lambdas/functions/control-plane/src/github/auth.ts
index b40b120bdd..6bf5220604 100644
--- a/lambdas/functions/control-plane/src/github/auth.ts
+++ b/lambdas/functions/control-plane/src/github/auth.ts
@@ -77,6 +77,66 @@ interface GitHubAppCredential {
let appCredentialsPromise: Promiselist(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 }))
installation_id = optional(string)
installation_id_ssm = optional(object({ arn = string, name = string }))
})) | `[]` | no |
| [ami\_housekeeper\_cleanup\_config](#input\_ami\_housekeeper\_cleanup\_config) | Configuration for AMI cleanup. | object({
maxItems = optional(number)
minimumDaysOld = optional(number)
amiFilters = optional(list(object({
Name = string
Values = list(string)
})))
launchTemplateNames = optional(list(string))
ssmParameterNames = optional(list(string))
dryRun = optional(bool)
}) | `{}` | no |
| [ami\_housekeeper\_lambda\_memory\_size](#input\_ami\_housekeeper\_lambda\_memory\_size) | Memory size limit in MB of the lambda. | `number` | `256` | no |
@@ -219,7 +219,7 @@ module "multi-runner" {
## Outputs
| Name | Description |
-| ---- | ----------- |
+|------|-------------|
| [binaries\_syncer\_map](#output\_binaries\_syncer\_map) | n/a |
| [instance\_termination\_handler](#output\_instance\_termination\_handler) | n/a |
| [instance\_termination\_watcher](#output\_instance\_termination\_watcher) | n/a |
diff --git a/modules/runners/README.md b/modules/runners/README.md
index f5da55e112..05c6ea5620 100644
--- a/modules/runners/README.md
+++ b/modules/runners/README.md
@@ -51,27 +51,27 @@ yarn run dist
## Requirements
| Name | Version |
-| ---- | ------- |
+|------|---------|
| [terraform](#requirement\_terraform) | >= 1.3.0 |
| [aws](#requirement\_aws) | >= 6.33 |
## Providers
| Name | Version |
-| ---- | ------- |
-| [aws](#provider\_aws) | 6.60.0 |
+|------|---------|
+| [aws](#provider\_aws) | >= 6.33 |
## Modules
| Name | Source | Version |
-| ---- | ------ | ------- |
+|------|--------|---------|
| [job\_retry](#module\_job\_retry) | ./job-retry | n/a |
| [pool](#module\_pool) | ./pool | n/a |
## Resources
| Name | Type |
-| ---- | ---- |
+|------|------|
| [aws_cloudwatch_event_rule.scale_down](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudwatch_event_rule) | resource |
| [aws_cloudwatch_event_rule.ssm_housekeeper](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudwatch_event_rule) | resource |
| [aws_cloudwatch_event_target.scale_down](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudwatch_event_target) | resource |
@@ -135,7 +135,7 @@ yarn run dist
## Inputs
| Name | Description | Type | Default | Required |
-| ---- | ----------- | ---- | ------- | :------: |
+|------|-------------|------|---------|:--------:|
| [ami](#input\_ami) | AMI configuration for the action runner instances. This object allows you to specify all AMI-related settings in one place.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` | no |
| [associate\_public\_ipv4\_address](#input\_associate\_public\_ipv4\_address) | Associate public IPv4 with the runner. Only tested with IPv4 | `bool` | `false` | no |
| [aws\_partition](#input\_aws\_partition) | (optional) partition for the base arn if not 'aws' | `string` | `"aws"` | no |
@@ -246,7 +246,7 @@ yarn run dist
## Outputs
| Name | Description |
-| ---- | ----------- |
+|------|-------------|
| [lambda\_pool](#output\_lambda\_pool) | n/a |
| [lambda\_pool\_log\_group](#output\_lambda\_pool\_log\_group) | n/a |
| [lambda\_scale\_down](#output\_lambda\_scale\_down) | n/a |
diff --git a/modules/runners/job-retry/README.md b/modules/runners/job-retry/README.md
index 267516fbbb..57c6d9dc91 100644
--- a/modules/runners/job-retry/README.md
+++ b/modules/runners/job-retry/README.md
@@ -11,26 +11,26 @@ The module is an inner module and used by the runner module when the opt-in feat
## Requirements
| Name | Version |
-| ---- | ------- |
+|------|---------|
| [terraform](#requirement\_terraform) | >= 1.3.0 |
| [aws](#requirement\_aws) | >= 6.21 |
## Providers
| Name | Version |
-| ---- | ------- |
+|------|---------|
| [aws](#provider\_aws) | >= 6.21 |
## Modules
| Name | Source | Version |
-| ---- | ------ | ------- |
+|------|--------|---------|
| [job\_retry](#module\_job\_retry) | ../../lambda | n/a |
## Resources
| Name | Type |
-| ---- | ---- |
+|------|------|
| [aws_iam_role_policy.job_retry](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource |
| [aws_lambda_event_source_mapping.job_retry](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/lambda_event_source_mapping) | resource |
| [aws_lambda_permission.job_retry](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/lambda_permission) | resource |
@@ -41,13 +41,13 @@ The module is an inner module and used by the runner module when the opt-in feat
## Inputs
| Name | Description | Type | Default | Required |
-| ---- | ----------- | ---- | ------- | :------: |
+|------|-------------|------|---------|:--------:|
| [config](#input\_config) | Configuration for the spot termination watcher lambda function.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)
additional_apps_manifest = optional(object({
name = string
arn = string
}), null)
additional_app_parameter_arns = optional(list(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
| Name | Description |
-| ---- | ----------- |
+|------|-------------|
| [job\_retry\_check\_queue](#output\_job\_retry\_check\_queue) | n/a |
| [lambda](#output\_lambda) | n/a |
diff --git a/modules/runners/pool/README.md b/modules/runners/pool/README.md
index 5a1ff2edcf..54b85d968e 100644
--- a/modules/runners/pool/README.md
+++ b/modules/runners/pool/README.md
@@ -9,14 +9,14 @@ The pool is an opt-in feature. To be able to use the count on a module level to
## Requirements
| Name | Version |
-| ---- | ------- |
+|------|---------|
| [terraform](#requirement\_terraform) | >= 0.14.1 |
| [aws](#requirement\_aws) | >= 6.21 |
## Providers
| Name | Version |
-| ---- | ------- |
+|------|---------|
| [aws](#provider\_aws) | >= 6.21 |
## Modules
@@ -26,7 +26,7 @@ No modules.
## Resources
| Name | Type |
-| ---- | ---- |
+|------|------|
| [aws_cloudwatch_log_group.pool](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudwatch_log_group) | resource |
| [aws_iam_role.pool](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role) | resource |
| [aws_iam_role.scheduler](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role) | resource |
@@ -47,7 +47,7 @@ No modules.
## Inputs
| 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)
additional_apps_manifest = optional(object({
name = string
arn = string
}), null)
additional_app_parameter_arns = optional(list(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
})
use_dedicated_host = bool
})
runners_maximum_count = number
instance_types = list(string)
instance_type_priorities = optional(map(number))
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
}))
include_busy_runners = bool
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 |
| [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 |
@@ -55,7 +55,7 @@ No modules.
## Outputs
| Name | Description |
-| ---- | ----------- |
+|------|-------------|
| [lambda](#output\_lambda) | n/a |
| [lambda\_log\_group](#output\_lambda\_log\_group) | n/a |
| [role\_pool](#output\_role\_pool) | n/a |
diff --git a/modules/ssm/README.md b/modules/ssm/README.md
index 73ba9a2d09..66bce354e2 100644
--- a/modules/ssm/README.md
+++ b/modules/ssm/README.md
@@ -8,15 +8,15 @@ This module is used for storing configuration of runners, registration tokens an
## Requirements
| Name | Version |
-| ---- | ------- |
+|------|---------|
| [terraform](#requirement\_terraform) | >= 1.3.0 |
| [aws](#requirement\_aws) | >= 6.21 |
## Providers
| Name | Version |
-| ---- | ------- |
-| [aws](#provider\_aws) | 6.60.0 |
+|------|---------|
+| [aws](#provider\_aws) | >= 6.21 |
## Modules
@@ -25,7 +25,7 @@ No modules.
## Resources
| Name | Type |
-| ---- | ---- |
+|------|------|
| [aws_ssm_parameter.additional_github_app_id](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/ssm_parameter) | resource |
| [aws_ssm_parameter.additional_github_app_installation_id](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/ssm_parameter) | resource |
| [aws_ssm_parameter.additional_github_app_key_base64](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/ssm_parameter) | resource |
@@ -37,7 +37,7 @@ No modules.
## Inputs
| Name | Description | Type | Default | Required |
-| ---- | ----------- | ---- | ------- | :------: |
+|------|-------------|------|---------|:--------:|
| [additional\_github\_apps](#input\_additional\_github\_apps) | Additional GitHub Apps for distributing API rate limit usage. | list(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 }))
installation_id = optional(string)
installation_id_ssm = optional(object({ arn = string, name = string }))
})) | `[]` | no |
| [github\_app](#input\_github\_app) | GitHub app parameters, see your github app. 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 |
@@ -47,7 +47,7 @@ No modules.
## Outputs
| Name | Description |
-| ---- | ----------- |
+|------|-------------|
| [additional\_app\_parameters](#output\_additional\_app\_parameters) | n/a |
| [additional\_apps\_manifest](#output\_additional\_apps\_manifest) | n/a |
| [parameters](#output\_parameters) | n/a |