diff --git a/README.md b/README.md index 68f02946d6..c92dc28877 100644 --- a/README.md +++ b/README.md @@ -112,6 +112,7 @@ Join our discord community via [this invite link](https://discord.gg/bxgXW8jJGh) | [ami\_housekeeper\_lambda\_timeout](#input\_ami\_housekeeper\_lambda\_timeout) | Time out of the lambda in seconds. | `number` | `300` | no | | [ami\_housekeeper\_lambda\_zip](#input\_ami\_housekeeper\_lambda\_zip) | File location of the lambda zip file. | `string` | `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\_dynamic\_labels\_policy](#input\_aws\_dynamic\_labels\_policy) | Experimental! Can be removed / changed without trigger a major release.
Optional AWS dynamic label policy evaluated by the webhook dispatcher.
Only effective when `enable_dynamic_labels = true`.

Jobs whose provider-specific dynamic labels violate the policy are rejected
with a 202 and a warning is logged. Currently this policy applies to EC2
override labels using the `ghr-ec2-*` prefix.

Evaluation:
1. Keys in `blocked_keys` are always rejected.
2. Keys in `restricted_keys` are allowed only when their value passes the rule.
3. Keys not listed in `blocked_keys` or `restricted_keys` are allowed.

Schema:
- `blocked_keys`: keys to reject outright.
- `restricted_keys`: map of key to value rule:
`{ allowed = [globs], denied = [globs], max = number|string }`.

Keys use the provider dynamic label suffix, not the full label. For example,
use `instance-type` for `ghr-ec2-instance-type`. | `any` | `null` | no | | [aws\_partition](#input\_aws\_partition) | (optiona) partition in the arn namespace to use if not 'aws' | `string` | `"aws"` | no | | [aws\_region](#input\_aws\_region) | AWS region. | `string` | n/a | yes | | [block\_device\_mappings](#input\_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`, `volume_initialization_rate`. |
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_initialization_rate = optional(number)
volume_size = number
volume_type = optional(string, "gp3")
}))
|
[
{
"volume_size": 30
}
]
| no | @@ -119,7 +120,6 @@ Join our discord community via [this invite link](https://discord.gg/bxgXW8jJGh) | [create\_service\_linked\_role\_spot](#input\_create\_service\_linked\_role\_spot) | (optional) create the service linked role for spot instances that is required by the scale-up lambda. | `bool` | `false` | no | | [delay\_webhook\_event](#input\_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. | `number` | `30` | no | | [disable\_runner\_autoupdate](#input\_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/) | `bool` | `false` | no | -| [ec2\_dynamic\_labels\_policy](#input\_ec2\_dynamic\_labels\_policy) | Experimental! Can be removed / changed without trigger a major release.
Optional policy for dynamic EC2 override labels evaluated by the webhook
dispatcher. Only effective when `enable_dynamic_labels = true`.

Jobs whose EC2 dynamic labels violate the policy are rejected with a 202 and a
warning is logged.

Evaluation:
1. Keys in `blocked_keys` are always rejected.
2. Keys in `restricted_keys` are allowed only when their value passes the rule.
3. Keys not listed in `blocked_keys` or `restricted_keys` are allowed.

Schema:
- `blocked_keys`: keys to reject outright.
- `restricted_keys`: map of key to value rule:
`{ allowed = [globs], denied = [globs], max = number|string }`.

Keys use the `ghr-ec2-*` dynamic label suffix, not the full label. For example, use
`instance-type` for `ghr-ec2-instance-type`. | `any` | `null` | no | | [enable\_ami\_housekeeper](#input\_enable\_ami\_housekeeper) | Option to disable the lambda to clean up old AMIs. | `bool` | `false` | no | | [enable\_cloudwatch\_agent](#input\_enable\_cloudwatch\_agent) | Enables the cloudwatch agent on the ec2 runner instances. The runner uses a default config that can be overridden via `cloudwatch_config`. | `bool` | `true` | no | | [enable\_dynamic\_labels](#input\_enable\_dynamic\_labels) | Experimental! Can be removed / changed without trigger a major release. Enable dynamic EC2 configs based on workflow job labels. When enabled, jobs can request specific configs via the 'ghr-ec2-:' label (e.g., 'ghr-ec2-instance-type:t3.large'). When enabled, labels starting with `ghr-` are ignored during webhook label matching. | `bool` | `false` | no | @@ -142,11 +142,12 @@ Join our discord community via [this invite link](https://discord.gg/bxgXW8jJGh) | [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 | | [iam\_overrides](#input\_iam\_overrides) | This map provides the possibility to override some IAM defaults. Note that when using this variable, you are responsible for ensuring the role has necessary permissions to access required resources. `override_instance_profile`: When set to true, uses the instance profile name specified in `instance_profile_name` instead of creating a new instance profile. `override_runner_role`: When set to true, uses the role ARN specified in `runner_role_arn` instead of creating a new IAM role. |
object({
override_instance_profile = optional(bool, null)
instance_profile_name = optional(string, null)
override_runner_role = optional(bool, null)
runner_role_arn = optional(string, null)
})
|
{
"instance_profile_name": null,
"override_instance_profile": false,
"override_runner_role": false,
"runner_role_arn": null
}
| no | | [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\_allocation\_strategy](#input\_instance\_allocation\_strategy) | The allocation strategy for creating instances. For spot, AWS recommends `price-capacity-optimized`; for on-demand, use `lowest-price` or `prioritized`. 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 | | [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\_target\_capacity\_type](#input\_instance\_target\_capacity\_type) | Default lifecycle used for runner instances, can be either `spot` or `on-demand`. | `string` | `"spot"` | no | | [instance\_termination\_watcher](#input\_instance\_termination\_watcher) | Configuration for the instance termination watcher. This feature is Beta, changes will not trigger a major release as long in beta.

`enable`: Enable or disable the spot termination watcher.
'features': Enable or disable features of the termination watcher.
`enable_runner_deregistration`: Enable or disable deregistering the runner from GitHub when its EC2 instance is terminated.
`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)
}), {})
enable_runner_deregistration = 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 | +| [instance\_type\_priorities](#input\_instance\_type\_priorities) | A map of instance type to priority for the `prioritized` and `capacity-optimized-prioritized` allocation strategies. Lower numbers mean higher priority. If not provided, priorities are assigned based on the order of `instance_types`. | `map(number)` | `null` | no | | [instance\_types](#input\_instance\_types) | List of instance types for the action runner. Defaults are based on runner\_os (al2023 for linux, macOS Sequoia for osx, Windows Server Core for win). | `list(string)` |
[
"m5.large",
"c5.large"
]
| no | | [job\_queue\_retention\_in\_seconds](#input\_job\_queue\_retention\_in\_seconds) | The number of seconds the job is held in the queue before it is purged. | `number` | `86400` | no | | [job\_retry](#input\_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.

`enable`: Enable or disable the job retry feature.
`delay_in_seconds`: The delay in seconds before the job retry check lambda will check the job status.
`delay_backoff`: The backoff factor for the delay.
`lambda_memory_size`: Memory size limit in MB for the job retry check lambda.
`lambda_timeout`: Time out of the job retry check lambda in seconds.
`max_attempts`: The maximum number of attempts to retry the job. |
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)
})
| `{}` | no | @@ -222,7 +223,7 @@ Join our discord community via [this invite link](https://discord.gg/bxgXW8jJGh) | [runners\_scale\_up\_lambda\_timeout](#input\_runners\_scale\_up\_lambda\_timeout) | Time out for the scale up lambda in seconds. | `number` | `30` | no | | [runners\_ssm\_housekeeper](#input\_runners\_ssm\_housekeeper) | Configuration for the SSM housekeeper lambda. This lambda deletes token / JIT config from SSM.

`schedule_expression`: is used to configure the schedule for the lambda.
`enabled`: enable or disable the lambda trigger via the EventBridge.
`lambda_memory_size`: lambda memory size limit.
`lambda_timeout`: timeout for the lambda in seconds.
`config`: configuration for the lambda function. Token path will be read by default from the module. |
object({
schedule_expression = optional(string, "rate(1 day)")
enabled = optional(bool, true)
lambda_memory_size = optional(number, 512)
lambda_timeout = optional(number, 60)
config = object({
tokenPath = optional(string)
minimumDaysOld = optional(number, 1)
dryRun = optional(bool, false)
})
})
|
{
"config": {}
}
| no | | [scale\_down\_schedule\_expression](#input\_scale\_down\_schedule\_expression) | Scheduler expression to check every x for scale down. | `string` | `"cron(*/5 * * * ? *)"` | no | -| [scale\_errors](#input\_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 | `list(string)` |
[
"UnfulfillableCapacity",
"MaxSpotInstanceCountExceeded",
"TargetCapacityLimitExceededException",
"RequestLimitExceeded",
"ResourceLimitExceeded",
"MaxSpotInstanceCountExceeded",
"MaxSpotFleetRequestCountExceeded",
"InsufficientInstanceCapacity",
"InsufficientCapacityOnHost"
]
| no | +| [scale\_errors](#input\_scale\_errors) | List of AWS error codes that should trigger retry during scale up. This list replaces the module default scale-up retry errors | `list(string)` |
[
"UnfulfillableCapacity",
"MaxSpotInstanceCountExceeded",
"TargetCapacityLimitExceededException",
"RequestLimitExceeded",
"ResourceLimitExceeded",
"MaxSpotInstanceCountExceeded",
"MaxSpotFleetRequestCountExceeded",
"InsufficientInstanceCapacity",
"InsufficientCapacityOnHost"
]
| no | | [scale\_up\_reserved\_concurrent\_executions](#input\_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. | `number` | `1` | no | | [ssm\_paths](#input\_ssm\_paths) | The root path used in SSM to store configuration and secrets. |
object({
root = optional(string, "github-action-runners")
app = optional(string, "app")
runners = optional(string, "runners")
webhook = optional(string, "webhook")
use_prefix = optional(bool, true)
})
| `{}` | no | | [state\_event\_rule\_binaries\_syncer](#input\_state\_event\_rule\_binaries\_syncer) | Option to disable EventBridge Lambda trigger for the binary syncer, useful to stop automatic updates of binary distribution | `string` | `"ENABLED"` | no | diff --git a/docs/configuration.md b/docs/configuration.md index 2cdf2e95a9..ab68d41905 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -336,7 +336,7 @@ Below is an example of the log messages created. [!WARNING] **Security implication:** Dynamic labels are extracted from the `runs-on` labels in incoming `workflow_job` webhook events. These labels originate from what -users define in their workflow files. Any user with permission to create or modify workflows can inject arbitrary EC2 configuration values — including instance types, AMI IDs, subnet IDs, EBS volumes, placement settings, and more. Unless constrained with `ec2_dynamic_labels_policy`, these values are not validated against label-specific rules before being passed to the EC2 CreateFleet API. This means a malicious or careless workflow author could, for example: +users define in their workflow files. Any user with permission to create or modify workflows can inject arbitrary EC2 configuration values — including instance types, AMI IDs, subnet IDs, EBS volumes, placement settings, and more. Unless constrained with `aws_dynamic_labels_policy` in the root module, or `matcherConfig.awsDynamicLabelsPolicy` when configuring matcher entries directly, these values are not validated against label-specific rules before being passed to the EC2 CreateFleet API. This means a malicious or careless workflow author could, for example: - Launch expensive instance types (e.g., `p5.48xlarge`) to inflate costs - Override the AMI (`ghr-ec2-image-id`) to boot a compromised image @@ -367,7 +367,7 @@ module "runners" { ... enable_dynamic_labels = true - ec2_dynamic_labels_policy = { + aws_dynamic_labels_policy = { blocked_keys = ["image-id", "subnet-id"] restricted_keys = { @@ -385,6 +385,8 @@ module "runners" { } ``` +The root module variable is named `aws_dynamic_labels_policy`. The webhook matcher config receives the same policy under `matcherConfig.awsDynamicLabelsPolicy`. If you configure `runner_matcher_config` or `multi_runner_config.matcherConfig` directly, use `awsDynamicLabelsPolicy` for this policy. + The policy is evaluated by dynamic label key: 1. Keys in `blocked_keys` are always rejected. diff --git a/lambdas/functions/control-plane/package.json b/lambdas/functions/control-plane/package.json index 8f978c0d17..f3a3481b50 100644 --- a/lambdas/functions/control-plane/package.json +++ b/lambdas/functions/control-plane/package.json @@ -32,6 +32,7 @@ "dependencies": { "@aws-github-runner/aws-powertools-util": "*", "@aws-github-runner/aws-ssm-util": "*", + "@aws-github-runner/runner-provider": "*", "@aws-lambda-powertools/parameters": "^2.31.0", "@aws-sdk/client-ec2": "^3.1009.0", "@aws-sdk/client-sqs": "^3.1009.0", diff --git a/lambdas/functions/control-plane/src/aws/runners.d.ts b/lambdas/functions/control-plane/src/aws/ec2-runners.d.ts similarity index 96% rename from lambdas/functions/control-plane/src/aws/runners.d.ts rename to lambdas/functions/control-plane/src/aws/ec2-runners.d.ts index 770106a98d..4df7d82fb5 100644 --- a/lambdas/functions/control-plane/src/aws/runners.d.ts +++ b/lambdas/functions/control-plane/src/aws/ec2-runners.d.ts @@ -7,7 +7,7 @@ import { Placement, FleetBlockDeviceMappingRequest, } from '@aws-sdk/client-ec2'; -import { LambdaRunnerSource } from '../scale-runners/scale-up'; +import type { LambdaRunnerSource } from '../scale-runners/types'; export type RunnerType = 'Org' | 'Repo'; diff --git a/lambdas/functions/control-plane/src/aws/runners.ts b/lambdas/functions/control-plane/src/aws/ec2-runners.ts similarity index 99% rename from lambdas/functions/control-plane/src/aws/runners.ts rename to lambdas/functions/control-plane/src/aws/ec2-runners.ts index 799bc0d4a2..8e83c93b33 100644 --- a/lambdas/functions/control-plane/src/aws/runners.ts +++ b/lambdas/functions/control-plane/src/aws/ec2-runners.ts @@ -23,7 +23,7 @@ import { getParameter } from '@aws-github-runner/aws-ssm-util'; import moment from 'moment'; import ScaleError from './../scale-runners/ScaleError'; -import * as Runners from './runners.d'; +import * as Runners from './ec2-runners.d'; const logger = createChildLogger('runners'); diff --git a/lambdas/functions/control-plane/src/aws/runners.test.ts b/lambdas/functions/control-plane/src/aws/runners.test.ts index 03d5b386e2..e95931033c 100644 --- a/lambdas/functions/control-plane/src/aws/runners.test.ts +++ b/lambdas/functions/control-plane/src/aws/runners.test.ts @@ -22,9 +22,9 @@ import 'aws-sdk-client-mock-jest/vitest'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import ScaleError from './../scale-runners/ScaleError'; -import { createRunner, listEC2Runners, tag, terminateRunner, untag } from './runners'; -import type { Ec2OverrideConfig, RunnerInfo, RunnerInputParameters, RunnerType } from './runners.d'; -import { LambdaRunnerSource } from '../scale-runners/scale-up'; +import { createRunner, listEC2Runners, tag, terminateRunner, untag } from './ec2-runners'; +import type { Ec2OverrideConfig, RunnerInfo, RunnerInputParameters, RunnerType } from './ec2-runners.d'; +import type { LambdaRunnerSource } from '../scale-runners/types'; process.env.AWS_REGION = 'eu-east-1'; const mockEC2Client = mockClient(EC2Client); diff --git a/lambdas/functions/control-plane/src/github/octokit.test.ts b/lambdas/functions/control-plane/src/github/octokit.test.ts index c104b69641..e6bc0fd2e3 100644 --- a/lambdas/functions/control-plane/src/github/octokit.test.ts +++ b/lambdas/functions/control-plane/src/github/octokit.test.ts @@ -1,5 +1,5 @@ import { Octokit } from '@octokit/rest'; -import { ActionRequestMessage } from '../scale-runners/scale-up'; +import type { ActionRequestMessage } from '../scale-runners/types'; import { getOctokit } from './octokit'; import { describe, it, expect, beforeEach, vi } from 'vitest'; import { createGithubAppAuth, createGithubInstallationAuth } from '../github/auth'; diff --git a/lambdas/functions/control-plane/src/github/octokit.ts b/lambdas/functions/control-plane/src/github/octokit.ts index 588ec62150..28181e6acc 100644 --- a/lambdas/functions/control-plane/src/github/octokit.ts +++ b/lambdas/functions/control-plane/src/github/octokit.ts @@ -1,5 +1,5 @@ import { Octokit } from '@octokit/rest'; -import { ActionRequestMessage } from '../scale-runners/scale-up'; +import type { ActionRequestMessage } from '../scale-runners/types'; import { createGithubAppAuth, createGithubInstallationAuth, createOctokitClient } from './auth'; function getErrorStatus(error: unknown): number | undefined { diff --git a/lambdas/functions/control-plane/src/lambda.test.ts b/lambdas/functions/control-plane/src/lambda.test.ts index 2c9a98e420..5c79d78342 100644 --- a/lambdas/functions/control-plane/src/lambda.test.ts +++ b/lambdas/functions/control-plane/src/lambda.test.ts @@ -5,7 +5,8 @@ import { addMiddleware, adjustPool, scaleDownHandler, scaleUpHandler, ssmHouseke import { adjust } from './pool/pool'; import ScaleError from './scale-runners/ScaleError'; import { scaleDown } from './scale-runners/scale-down'; -import { ActionRequestMessage, scaleUp } from './scale-runners/scale-up'; +import { scaleUp } from './scale-runners/scale-up'; +import type { ActionRequestMessage } from './scale-runners/types'; import { cleanSSMTokens } from './scale-runners/ssm-housekeeper'; import { checkAndRetryJob } from './scale-runners/job-retry'; import { describe, it, expect, vi, MockedFunction, beforeEach } from 'vitest'; @@ -248,14 +249,14 @@ describe('Test scale down lambda wrapper.', () => { describe('Adjust pool.', () => { it('Receive message to adjust pool.', async () => { vi.mocked(adjust).mockResolvedValue(); - await expect(adjustPool({ poolSize: 2 }, context)).resolves.not.toThrow(); + await expect(adjustPool({ poolSize: 2, type: 'ec2' }, context)).resolves.not.toThrow(); }); it('Handle error for adjusting pool.', async () => { const error = new Error('Handle error for adjusting pool.'); vi.mocked(adjust).mockRejectedValue(error); const logSpy = vi.spyOn(logger, 'error'); - await adjustPool({ poolSize: 0 }, context); + await adjustPool({ poolSize: 0, type: 'ec2' }, context); expect(logSpy).toHaveBeenCalledWith(`Handle error for adjusting pool. ${error.message}`, { error }); }); }); diff --git a/lambdas/functions/control-plane/src/lambda.ts b/lambdas/functions/control-plane/src/lambda.ts index e2a0451c95..9a67ccd0f8 100644 --- a/lambdas/functions/control-plane/src/lambda.ts +++ b/lambdas/functions/control-plane/src/lambda.ts @@ -6,7 +6,8 @@ import { Context, type SQSBatchItemFailure, type SQSBatchResponse, SQSEvent } fr import { PoolEvent, adjust } from './pool/pool'; import ScaleError from './scale-runners/ScaleError'; import { scaleDown } from './scale-runners/scale-down'; -import { type ActionRequestMessage, type ActionRequestMessageSQS, scaleUp } from './scale-runners/scale-up'; +import { scaleUp } from './scale-runners/scale-up'; +import type { ActionRequestMessage, ActionRequestMessageSQS } from './scale-runners/types'; import { SSMCleanupOptions, cleanSSMTokens } from './scale-runners/ssm-housekeeper'; import { checkAndRetryJob } from './scale-runners/job-retry'; diff --git a/lambdas/functions/control-plane/src/local-pool.ts b/lambdas/functions/control-plane/src/local-pool.ts index ab8c74a1a0..d743ac759b 100644 --- a/lambdas/functions/control-plane/src/local-pool.ts +++ b/lambdas/functions/control-plane/src/local-pool.ts @@ -1,7 +1,7 @@ import { adjust } from './pool/pool'; export function run(): void { - adjust({ poolSize: 1 }) + adjust({ poolSize: 1, type: 'ec2' }) .then() .catch((e) => { console.log(e); diff --git a/lambdas/functions/control-plane/src/modules.d.ts b/lambdas/functions/control-plane/src/modules.d.ts index 0ec63317db..53247cf6c6 100644 --- a/lambdas/functions/control-plane/src/modules.d.ts +++ b/lambdas/functions/control-plane/src/modules.d.ts @@ -16,6 +16,7 @@ declare namespace NodeJS { PARAMETER_GITHUB_APP_ID_NAME: string; PARAMETER_GITHUB_APP_KEY_BASE64_NAME: string; RUNNER_OWNER: string; + RUNNER_PROVIDER_TYPE?: string; SCALE_DOWN_CONFIG: string; SSM_TOKEN_PATH: string; SSM_CLEANUP_CONFIG: string; diff --git a/lambdas/functions/control-plane/src/pool/ec2-pool.ts b/lambdas/functions/control-plane/src/pool/ec2-pool.ts new file mode 100644 index 0000000000..84cde7aa9f --- /dev/null +++ b/lambdas/functions/control-plane/src/pool/ec2-pool.ts @@ -0,0 +1,118 @@ +import { createChildLogger } from '@aws-github-runner/aws-powertools-util'; +import yn from 'yn'; + +import { bootTimeExceeded, listEC2Runners } from '../aws/ec2-runners'; +import type { RunnerList } from '../aws/ec2-runners.d'; +import { createRunners } from '../scale-runners/ec2'; +import type { CreateEC2RunnerConfig } from '../scale-runners/ec2'; +import type { CreatePoolRunnersInput, ListPoolRunnersInput, PoolRunnerProvider, RunnerStatus } from './pool-provider'; + +const logger = createChildLogger('pool'); + +interface Ec2PoolProviderConfig { + environment: string; + subnets: string[]; + launchTemplateName: string; + ec2instanceCriteria: CreateEC2RunnerConfig['ec2instanceCriteria']; + amiIdSsmParameterName?: string; + tracingEnabled?: boolean; + onDemandFailoverOnError: string[]; + scaleErrors: string[]; +} + +function loadEc2PoolProviderConfig(): Ec2PoolProviderConfig { + return { + environment: process.env.ENVIRONMENT, + subnets: process.env.SUBNET_IDS.split(','), + launchTemplateName: process.env.LAUNCH_TEMPLATE_NAME, + ec2instanceCriteria: { + instanceTypes: process.env.INSTANCE_TYPES.split(','), + instanceTypePriorities: process.env.INSTANCE_TYPE_PRIORITIES + ? (JSON.parse(process.env.INSTANCE_TYPE_PRIORITIES) as Record) + : undefined, + targetCapacityType: process.env.INSTANCE_TARGET_CAPACITY_TYPE, + maxSpotPrice: process.env.INSTANCE_MAX_SPOT_PRICE, + instanceAllocationStrategy: process.env.INSTANCE_ALLOCATION_STRATEGY || 'lowest-price', + }, + amiIdSsmParameterName: process.env.AMI_ID_SSM_PARAMETER_NAME, + tracingEnabled: yn(process.env.POWERTOOLS_TRACE_ENABLED, { default: false }), + onDemandFailoverOnError: process.env.ENABLE_ON_DEMAND_FAILOVER_FOR_ERRORS + ? (JSON.parse(process.env.ENABLE_ON_DEMAND_FAILOVER_FOR_ERRORS) as [string]) + : [], + scaleErrors: JSON.parse(process.env.SCALE_ERRORS) as [string], + }; +} + +async function listEc2PoolRunners({ + environment, + runnerOwner, + runnerType, +}: ListPoolRunnersInput): Promise { + return await listEC2Runners({ + environment, + runnerOwner, + runnerType, + statuses: ['running'], + }); +} + +async function createEc2PoolRunners({ + githubRunnerConfig, + numberOfRunners, + githubInstallationClient, +}: CreatePoolRunnersInput): Promise { + const config = loadEc2PoolProviderConfig(); + + return await createRunners( + githubRunnerConfig, + { + ec2instanceCriteria: config.ec2instanceCriteria, + environment: config.environment, + launchTemplateName: config.launchTemplateName, + subnets: config.subnets, + amiIdSsmParameterName: config.amiIdSsmParameterName, + tracingEnabled: config.tracingEnabled, + onDemandFailoverOnError: config.onDemandFailoverOnError, + scaleErrors: config.scaleErrors, + }, + numberOfRunners, + githubInstallationClient, + 'pool-lambda', + ); +} + +export function createEc2PoolProvider(): Omit { + return { + listRunners: listEc2PoolRunners, + countAvailableRunners: calculateEc2PoolSize, + createRunners: createEc2PoolRunners, + }; +} + +export function calculateEc2PoolSize( + ec2runners: RunnerList[], + runnerStatus: Map, + includeBusyRunners = false, +): number { + // Runner should be considered idle if it is still booting, or is idle in GitHub + let numberOfRunnersInPool = 0; + for (const ec2Instance of ec2runners) { + if ( + (runnerStatus.get(ec2Instance.instanceId)?.busy === false || includeBusyRunners) && + runnerStatus.get(ec2Instance.instanceId)?.status === 'online' + ) { + numberOfRunnersInPool++; + logger.debug(`Runner ${ec2Instance.instanceId} is idle in GitHub and counted as part of the pool`); + } else if (runnerStatus.get(ec2Instance.instanceId) != null) { + logger.debug(`Runner ${ec2Instance.instanceId} is not idle in GitHub and NOT counted as part of the pool`); + } else if (!bootTimeExceeded(ec2Instance)) { + numberOfRunnersInPool++; + logger.info(`Runner ${ec2Instance.instanceId} is still booting and counted as part of the pool`); + } else { + logger.debug( + `Runner ${ec2Instance.instanceId} is not idle in GitHub nor booting and not counted as part of the pool`, + ); + } + } + return numberOfRunnersInPool; +} diff --git a/lambdas/functions/control-plane/src/pool/pool-provider.ts b/lambdas/functions/control-plane/src/pool/pool-provider.ts new file mode 100644 index 0000000000..fa12b43a5e --- /dev/null +++ b/lambdas/functions/control-plane/src/pool/pool-provider.ts @@ -0,0 +1,31 @@ +import type { Octokit } from '@octokit/rest'; +import type { RunnerProvider } from '@aws-github-runner/runner-provider'; + +import type { CreateGitHubRunnerConfig, GitHubRunnerType } from '../scale-runners/types'; + +export interface RunnerStatus { + busy: boolean; + status: string; +} + +export interface ListPoolRunnersInput { + environment: string; + runnerOwner: string; + runnerType: GitHubRunnerType; +} + +export interface CreatePoolRunnersInput { + githubRunnerConfig: CreateGitHubRunnerConfig; + numberOfRunners: number; + githubInstallationClient: Octokit; +} + +export interface PoolRunnerProvider extends RunnerProvider { + listRunners(input: ListPoolRunnersInput): Promise; + countAvailableRunners( + runners: TRunner[], + runnerStatus: Map, + includeBusyRunners: boolean, + ): number; + createRunners(input: CreatePoolRunnersInput): Promise; +} diff --git a/lambdas/functions/control-plane/src/pool/pool.test.ts b/lambdas/functions/control-plane/src/pool/pool.test.ts index c326fe0f47..86ccdc163e 100644 --- a/lambdas/functions/control-plane/src/pool/pool.test.ts +++ b/lambdas/functions/control-plane/src/pool/pool.test.ts @@ -2,10 +2,13 @@ import { Octokit } from '@octokit/rest'; import moment from 'moment-timezone'; import * as nock from 'nock'; -import { listEC2Runners } from '../aws/runners'; +import { bootTimeExceeded, listEC2Runners } from '../aws/ec2-runners'; +import type { RunnerList } from '../aws/ec2-runners.d'; import * as ghAuth from '../github/auth'; -import { createRunners, getGitHubEnterpriseApiUrl } from '../scale-runners/scale-up'; +import { createRunners } from '../scale-runners/ec2'; +import { getGitHubEnterpriseApiUrl } from '../scale-runners/github-runner'; import { adjust } from './pool'; +import { calculateEc2PoolSize } from './ec2-pool'; import { describe, it, expect, beforeEach, vi, MockedClass } from 'vitest'; const mockOctokit = { @@ -25,7 +28,7 @@ vi.mock('@octokit/rest', () => ({ }), })); -vi.mock('./../aws/runners', async () => ({ +vi.mock('./../aws/ec2-runners', async () => ({ listEC2Runners: vi.fn(), // Include any other functions from the module that might be used bootTimeExceeded: vi.fn(), @@ -36,14 +39,16 @@ vi.mock('./../github/auth', async () => ({ createOctokitClient: vi.fn(), })); -vi.mock('../scale-runners/scale-up', async () => ({ - scaleUp: vi.fn(), +vi.mock('../scale-runners/ec2', async () => ({ createRunners: vi.fn(), +})); + +vi.mock('../scale-runners/github-runner', async () => ({ getGitHubEnterpriseApiUrl: vi.fn().mockReturnValue({ ghesApiUrl: '', ghesBaseUrl: '', }), - // Include any other functions that might be needed + validateSsmParameterStoreTags: vi.fn().mockReturnValue([]), })); const mocktokit = Octokit as MockedClass; @@ -51,6 +56,7 @@ const mockedAppAuth = vi.mocked(ghAuth.createGithubAppAuth); const mockedInstallationAuth = vi.mocked(ghAuth.createGithubInstallationAuth); const mockCreateClient = vi.mocked(ghAuth.createOctokitClient); const mockListRunners = vi.mocked(listEC2Runners); +const mockBootTimeExceeded = vi.mocked(bootTimeExceeded); const cleanEnv = process.env; @@ -190,7 +196,7 @@ describe('Test simple pool.', () => { }); }); it('Top up pool with pool size 2 registered.', async () => { - await adjust({ poolSize: 3 }); + await adjust({ poolSize: 3, type: 'ec2' }); expect(createRunners).toHaveBeenCalledTimes(1); expect(createRunners).toHaveBeenCalledWith( expect.anything(), @@ -201,8 +207,43 @@ describe('Test simple pool.', () => { ); }); + it('Defaults legacy pool events without a provider type to EC2.', async () => { + await adjust({ poolSize: 10 }); + expect(mockListRunners).toHaveBeenCalledWith({ + environment: 'unit-test-environment', + runnerOwner: ORG, + runnerType: 'Org', + statuses: ['running'], + }); + expect(createRunners).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + 8, + expect.anything(), + 'pool-lambda', + ); + }); + + it('Selects the EC2 pool provider case-insensitively.', async () => { + await adjust({ poolSize: 10, type: ' EC2 ' }); + expect(createRunners).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + 8, + expect.anything(), + 'pool-lambda', + ); + }); + + it('Rejects unsupported pool provider types.', async () => { + await expect(adjust({ poolSize: 10, type: 'microvm' })).rejects.toThrow( + "Unsupported runner provider type 'microvm'", + ); + expect(mockListRunners).not.toHaveBeenCalled(); + }); + it('Should not top up if pool size is reached.', async () => { - await adjust({ poolSize: 1 }); + await adjust({ poolSize: 1, type: 'ec2' }); expect(createRunners).not.toHaveBeenCalled(); }); @@ -228,7 +269,7 @@ describe('Test simple pool.', () => { ]); // 2 idle + 1 booting = 3, top up with 2 to match a pool of 5 - await adjust({ poolSize: 5 }); + await adjust({ poolSize: 5, type: 'ec2' }); expect(createRunners).toHaveBeenCalled(); // Access the numberOfRunners without assuming a specific position // Just test that the function was called @@ -258,7 +299,7 @@ describe('Test simple pool.', () => { }, ]); - await adjust({ poolSize: 2 }); + await adjust({ poolSize: 2, type: 'ec2' }); expect(createRunners).not.toHaveBeenCalled(); }); }); @@ -272,7 +313,7 @@ describe('Test simple pool.', () => { }); it('Top up if the pool size is set to 5', async () => { - await adjust({ poolSize: 5 }); + await adjust({ poolSize: 5, type: 'ec2' }); // 2 idle, top up with 3 to match a pool of 5 expect(createRunners).toHaveBeenCalledWith( expect.anything(), @@ -293,7 +334,7 @@ describe('Test simple pool.', () => { }); it('Top up if the pool size is set to 5', async () => { - await adjust({ poolSize: 5 }); + await adjust({ poolSize: 5, type: 'ec2' }); // 2 idle, top up with 3 to match a pool of 5 expect(createRunners).toHaveBeenCalledWith( expect.anything(), @@ -349,7 +390,7 @@ describe('Test simple pool.', () => { }, ]); - await adjust({ poolSize: 5 }); + await adjust({ poolSize: 5, type: 'ec2' }); // 2 idle, 2 prefixed idle top up with 1 to match a pool of 5 expect(createRunners).toHaveBeenCalledWith( expect.anything(), @@ -373,13 +414,13 @@ describe('Test simple pool.', () => { // 4 running runners (2 idle, 1 busy, 1 offline) already meet the maximum, so a large pool size // must not create more. This is the over-provisioning case from issue #5186. process.env.RUNNERS_MAXIMUM_COUNT = '4'; - await adjust({ poolSize: 10 }); + await adjust({ poolSize: 10, type: 'ec2' }); expect(createRunners).not.toHaveBeenCalled(); }); it('Should not top up when the total number of running runners exceeds the maximum.', async () => { process.env.RUNNERS_MAXIMUM_COUNT = '3'; - await adjust({ poolSize: 10 }); + await adjust({ poolSize: 10, type: 'ec2' }); expect(createRunners).not.toHaveBeenCalled(); }); @@ -387,7 +428,7 @@ describe('Test simple pool.', () => { // 4 running runners with a maximum of 6 leaves headroom for 2, even though the pool of 10 and the // 2 idle runners would otherwise request a top-up of 8. process.env.RUNNERS_MAXIMUM_COUNT = '6'; - await adjust({ poolSize: 10 }); + await adjust({ poolSize: 10, type: 'ec2' }); expect(createRunners).toHaveBeenCalledWith( expect.anything(), expect.anything(), @@ -401,7 +442,7 @@ describe('Test simple pool.', () => { // Headroom (6 - 4 = 2) is larger than the pool demand (5 - 2 idle = 3 would exceed it, so use a // pool that stays within headroom): pool of 3 with 2 idle requests 1, which is under the cap. process.env.RUNNERS_MAXIMUM_COUNT = '6'; - await adjust({ poolSize: 3 }); + await adjust({ poolSize: 3, type: 'ec2' }); expect(createRunners).toHaveBeenCalledWith( expect.anything(), expect.anything(), @@ -414,7 +455,7 @@ describe('Test simple pool.', () => { it('Should ignore the maximum when set to -1 (unlimited).', async () => { process.env.RUNNERS_MAXIMUM_COUNT = '-1'; // 2 idle of 4 running, pool of 10 tops up with 8 regardless of how many are already running. - await adjust({ poolSize: 10 }); + await adjust({ poolSize: 10, type: 'ec2' }); expect(createRunners).toHaveBeenCalledWith( expect.anything(), expect.anything(), @@ -432,12 +473,12 @@ describe('Test simple pool.', () => { it('Should not top up when pool size matches runners including busy online runners.', async () => { // Without INCLUDE_BUSY_RUNNERS: 2 in pool (i-1-idle, i-4-idle-older). With it: 3 (adds i-2-busy). - await adjust({ poolSize: 3 }); + await adjust({ poolSize: 3, type: 'ec2' }); expect(createRunners).not.toHaveBeenCalled(); }); it('Should top up by two runners when pool size is 5 and busy runners count toward the pool.', async () => { - await adjust({ poolSize: 5 }); + await adjust({ poolSize: 5, type: 'ec2' }); // 3 in pool (idle, busy, older idle); need 2 more expect(createRunners).toHaveBeenCalledWith( expect.anything(), @@ -449,3 +490,42 @@ describe('Test simple pool.', () => { }); }); }); + +describe('calculateEc2PoolSize', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('counts registered online idle runners', () => { + const runners: RunnerList[] = [{ instanceId: 'i-idle' }]; + const runnerStatus = new Map([['i-idle', { busy: false, status: 'online' }]]); + + expect(calculateEc2PoolSize(runners, runnerStatus)).toBe(1); + expect(mockBootTimeExceeded).not.toHaveBeenCalled(); + }); + + it('does not count registered busy or offline runners', () => { + const runners: RunnerList[] = [{ instanceId: 'i-busy' }, { instanceId: 'i-offline' }]; + const runnerStatus = new Map([ + ['i-busy', { busy: true, status: 'online' }], + ['i-offline', { busy: false, status: 'offline' }], + ]); + + expect(calculateEc2PoolSize(runners, runnerStatus)).toBe(0); + expect(mockBootTimeExceeded).not.toHaveBeenCalled(); + }); + + it('counts unregistered runners that are still booting', () => { + const runners: RunnerList[] = [{ instanceId: 'i-booting' }]; + mockBootTimeExceeded.mockReturnValue(false); + + expect(calculateEc2PoolSize(runners, new Map())).toBe(1); + }); + + it('does not count unregistered runners whose boot time expired', () => { + const runners: RunnerList[] = [{ instanceId: 'i-expired' }]; + mockBootTimeExceeded.mockReturnValue(true); + + expect(calculateEc2PoolSize(runners, new Map())).toBe(0); + }); +}); diff --git a/lambdas/functions/control-plane/src/pool/pool.ts b/lambdas/functions/control-plane/src/pool/pool.ts index 7d0cf483d4..5ef5fa897f 100644 --- a/lambdas/functions/control-plane/src/pool/pool.ts +++ b/lambdas/functions/control-plane/src/pool/pool.ts @@ -1,55 +1,38 @@ import { Octokit } from '@octokit/rest'; import { createChildLogger } from '@aws-github-runner/aws-powertools-util'; +import { resolveRunnerProviderType } from '@aws-github-runner/runner-provider'; import yn from 'yn'; -import { bootTimeExceeded, listEC2Runners } from '../aws/runners'; -import { RunnerList } from '../aws/runners.d'; import { createGithubAppAuth, createGithubInstallationAuth, createOctokitClient } from '../github/auth'; -import { createRunners, getGitHubEnterpriseApiUrl } from '../scale-runners/scale-up'; -import { validateSsmParameterStoreTags } from '../scale-runners/scale-up'; +import { createPoolRunnerProvider } from '../runner-provider-registry'; +import { getGitHubEnterpriseApiUrl, validateSsmParameterStoreTags } from '../scale-runners/github-runner'; +import type { RunnerStatus } from './pool-provider'; const logger = createChildLogger('pool'); export interface PoolEvent { poolSize: number; -} - -interface RunnerStatus { - busy: boolean; - status: string; + type?: string; } export async function adjust(event: PoolEvent): Promise { - logger.info(`Checking current pool size against pool of size: ${event.poolSize}`); + const runnerProviderType = resolveRunnerProviderType(event.type); + const runnerProvider = createPoolRunnerProvider(runnerProviderType); + logger.info(`Checking current ${runnerProvider.type} pool size against pool of size: ${event.poolSize}`); const runnerLabels = process.env.RUNNER_LABELS || ''; const runnerGroup = process.env.RUNNER_GROUP_NAME || ''; const runnerNamePrefix = process.env.RUNNER_NAME_PREFIX || ''; const environment = process.env.ENVIRONMENT; const ssmTokenPath = process.env.SSM_TOKEN_PATH; const ssmConfigPath = process.env.SSM_CONFIG_PATH || ''; - const subnets = process.env.SUBNET_IDS.split(','); - const instanceTypes = process.env.INSTANCE_TYPES.split(','); - const instanceTargetCapacityType = process.env.INSTANCE_TARGET_CAPACITY_TYPE; const ephemeral = yn(process.env.ENABLE_EPHEMERAL_RUNNERS, { default: false }); const enableJitConfig = yn(process.env.ENABLE_JIT_CONFIG, { default: ephemeral }); const disableAutoUpdate = yn(process.env.DISABLE_RUNNER_AUTOUPDATE, { default: false }); - const launchTemplateName = process.env.LAUNCH_TEMPLATE_NAME; - const instanceMaxSpotPrice = process.env.INSTANCE_MAX_SPOT_PRICE; - const instanceAllocationStrategy = process.env.INSTANCE_ALLOCATION_STRATEGY || 'lowest-price'; // same as AWS default - const instanceTypePriorities = process.env.INSTANCE_TYPE_PRIORITIES - ? (JSON.parse(process.env.INSTANCE_TYPE_PRIORITIES) as Record) - : undefined; const runnerOwner = process.env.RUNNER_OWNER; - const amiIdSsmParameterName = process.env.AMI_ID_SSM_PARAMETER_NAME; - const tracingEnabled = yn(process.env.POWERTOOLS_TRACE_ENABLED, { default: false }); - const onDemandFailoverOnError = process.env.ENABLE_ON_DEMAND_FAILOVER_FOR_ERRORS - ? (JSON.parse(process.env.ENABLE_ON_DEMAND_FAILOVER_FOR_ERRORS) as [string]) - : []; const ssmParameterStoreTags: { Key: string; Value: string }[] = process.env.SSM_PARAMETER_STORE_TAGS && process.env.SSM_PARAMETER_STORE_TAGS.trim() !== '' ? validateSsmParameterStoreTags(process.env.SSM_PARAMETER_STORE_TAGS) : []; - const scaleErrors = JSON.parse(process.env.SCALE_ERRORS) as [string]; // -1 disables the maximum check, matching the scale-up lambda's semantics. Defaults to unlimited // when unset so the pool keeps its previous behavior on stacks that do not provide the variable. const maximumRunners = parseInt(process.env.RUNNERS_MAXIMUM_COUNT || '-1'); @@ -68,27 +51,26 @@ export async function adjust(event: PoolEvent): Promise { runnerNamePrefix, ); - // Look up the managed ec2 runners in AWS, but running does not mean idle - const ec2runners = await listEC2Runners({ + // Look up the managed provider runners, but running does not mean idle. + const poolRunners = await runnerProvider.listRunners({ environment, runnerOwner, runnerType: 'Org', - statuses: ['running'], }); - const numberOfRunnersInPool = calculatePooSize(ec2runners, runnerStatusses, includeBusyRunners); + const numberOfRunnersInPool = runnerProvider.countAvailableRunners(poolRunners, runnerStatusses, includeBusyRunners); let topUp = event.poolSize - numberOfRunnersInPool; // The pool must never push the total number of runners (busy + idle) past the configured maximum. - // ec2runners contains every running runner for this type, so its length is the current total and no + // poolRunners contains every running runner for this type, so its length is the current total and no // extra API call is needed. Without this clamp the pool keeps topping up against idle-only counts and // can overshoot runners_maximum_count, while the scale-up lambda correctly refuses to launch. if (maximumRunners !== -1 && topUp > 0) { - const headroom = maximumRunners - ec2runners.length; + const headroom = maximumRunners - poolRunners.length; if (topUp > headroom) { logger.info( `Capping pool top-up from ${topUp} to ${Math.max(headroom, 0)} to respect the maximum of ` + - `${maximumRunners} runners (currently ${ec2runners.length} running).`, + `${maximumRunners} runners (currently ${poolRunners.length} running).`, ); topUp = headroom; } @@ -96,8 +78,8 @@ export async function adjust(event: PoolEvent): Promise { if (topUp > 0) { logger.info(`The pool will be topped up with ${topUp} runners.`); - await createRunners( - { + await runnerProvider.createRunners({ + githubRunnerConfig: { ephemeral, enableJitConfig, ghesBaseUrl, @@ -111,26 +93,9 @@ export async function adjust(event: PoolEvent): Promise { ssmConfigPath, ssmParameterStoreTags, }, - { - ec2instanceCriteria: { - instanceTypes, - instanceTypePriorities, - targetCapacityType: instanceTargetCapacityType, - maxSpotPrice: instanceMaxSpotPrice, - instanceAllocationStrategy: instanceAllocationStrategy, - }, - environment, - launchTemplateName, - subnets, - amiIdSsmParameterName, - tracingEnabled, - onDemandFailoverOnError, - scaleErrors, - }, - topUp, + numberOfRunners: topUp, githubInstallationClient, - 'pool-lambda', - ); + }); } else { logger.info(`Pool will not be topped up. Found ${numberOfRunnersInPool} managed idle runners.`); } @@ -147,34 +112,6 @@ async function getInstallationId(ghesApiUrl: string, org: string): Promise, - includeBusyRunners: boolean, -): number { - // Runner should be considered idle if it is still booting, or is idle in GitHub - let numberOfRunnersInPool = 0; - for (const ec2Instance of ec2runners) { - if ( - (runnerStatus.get(ec2Instance.instanceId)?.busy === false || includeBusyRunners) && - runnerStatus.get(ec2Instance.instanceId)?.status === 'online' - ) { - numberOfRunnersInPool++; - logger.debug(`Runner ${ec2Instance.instanceId} is idle in GitHub and counted as part of the pool`); - } else if (runnerStatus.get(ec2Instance.instanceId) != null) { - logger.debug(`Runner ${ec2Instance.instanceId} is not idle in GitHub and NOT counted as part of the pool`); - } else if (!bootTimeExceeded(ec2Instance)) { - numberOfRunnersInPool++; - logger.info(`Runner ${ec2Instance.instanceId} is still booting and counted as part of the pool`); - } else { - logger.debug( - `Runner ${ec2Instance.instanceId} is not idle in GitHub nor booting and not counted as part of the pool`, - ); - } - } - return numberOfRunnersInPool; -} - async function getGitHubRegisteredRunnnerStatusses( ghClient: Octokit, runnerOwner: string, diff --git a/lambdas/functions/control-plane/src/runner-provider-registry.test.ts b/lambdas/functions/control-plane/src/runner-provider-registry.test.ts new file mode 100644 index 0000000000..f22de57530 --- /dev/null +++ b/lambdas/functions/control-plane/src/runner-provider-registry.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { createEc2PoolProvider } from './pool/ec2-pool'; +import type { PoolRunnerProvider } from './pool/pool-provider'; +import { + createPoolRunnerProvider, + createScaleDownRunnerProvider, + createScaleUpRunnerProvider, +} from './runner-provider-registry'; +import { createEc2ScaleDownProvider } from './scale-runners/ec2-scale-down'; +import { createEc2ScaleUpProvider } from './scale-runners/ec2-scale-up'; +import type { ScaleDownRunnerProvider } from './scale-runners/scale-down-provider'; +import type { ScaleUpRunnerProvider } from './scale-runners/scale-up-provider'; + +vi.mock('./pool/ec2-pool', () => ({ createEc2PoolProvider: vi.fn() })); +vi.mock('./scale-runners/ec2-scale-down', () => ({ createEc2ScaleDownProvider: vi.fn() })); +vi.mock('./scale-runners/ec2-scale-up', () => ({ createEc2ScaleUpProvider: vi.fn() })); + +const poolImplementation = { + listRunners: vi.fn(async () => []), + countAvailableRunners: vi.fn(() => 0), + createRunners: vi.fn(async () => []), +} satisfies Omit; + +const scaleUpImplementation = { + prepareGroup: vi.fn(async () => ({ runnerLabels: [], state: undefined })), + getCurrentRunners: vi.fn(async () => 0), + createRunners: vi.fn(async () => []), +} satisfies Omit; + +const scaleDownImplementation = { + list: vi.fn(async () => []), + bootTimeExceeded: vi.fn(() => false), + markOrphan: vi.fn(async () => undefined), + unmarkOrphan: vi.fn(async () => undefined), + terminate: vi.fn(async () => undefined), +} satisfies Omit; + +describe('runner provider registry', () => { + it('routes EC2 capabilities and injects the provider type', () => { + vi.mocked(createEc2PoolProvider).mockReturnValue(poolImplementation); + vi.mocked(createEc2ScaleUpProvider).mockReturnValue(scaleUpImplementation); + vi.mocked(createEc2ScaleDownProvider).mockReturnValue(scaleDownImplementation); + + expect(createPoolRunnerProvider('ec2')).toStrictEqual({ ...poolImplementation, type: 'ec2' }); + expect(createScaleUpRunnerProvider('ec2')).toStrictEqual({ ...scaleUpImplementation, type: 'ec2' }); + expect(createScaleDownRunnerProvider('ec2')).toStrictEqual({ ...scaleDownImplementation, type: 'ec2' }); + + expect(createEc2PoolProvider).toHaveBeenCalledTimes(1); + expect(createEc2ScaleUpProvider).toHaveBeenCalledTimes(1); + expect(createEc2ScaleDownProvider).toHaveBeenCalledTimes(1); + }); +}); diff --git a/lambdas/functions/control-plane/src/runner-provider-registry.ts b/lambdas/functions/control-plane/src/runner-provider-registry.ts new file mode 100644 index 0000000000..95c342ebdf --- /dev/null +++ b/lambdas/functions/control-plane/src/runner-provider-registry.ts @@ -0,0 +1,34 @@ +import type { RunnerProviderType } from '@aws-github-runner/runner-provider'; + +import { createEc2PoolProvider } from './pool/ec2-pool'; +import type { PoolRunnerProvider } from './pool/pool-provider'; +import { createEc2ScaleDownProvider } from './scale-runners/ec2-scale-down'; +import { createEc2ScaleUpProvider } from './scale-runners/ec2-scale-up'; +import type { ScaleDownRunnerProvider } from './scale-runners/scale-down-provider'; +import type { ScaleUpRunnerProvider } from './scale-runners/scale-up-provider'; + +interface RunnerProviderFactory { + pool: () => Omit; + scaleUp: () => Omit; + scaleDown: () => Omit; +} + +const runnerProviderFactories: Record = { + ec2: { + pool: createEc2PoolProvider, + scaleUp: createEc2ScaleUpProvider, + scaleDown: createEc2ScaleDownProvider, + }, +}; + +export function createPoolRunnerProvider(type: RunnerProviderType): PoolRunnerProvider { + return { ...runnerProviderFactories[type].pool(), type }; +} + +export function createScaleUpRunnerProvider(type: RunnerProviderType): ScaleUpRunnerProvider { + return { ...runnerProviderFactories[type].scaleUp(), type }; +} + +export function createScaleDownRunnerProvider(type: RunnerProviderType): ScaleDownRunnerProvider { + return { ...runnerProviderFactories[type].scaleDown(), type }; +} diff --git a/lambdas/functions/control-plane/src/scale-runners/ScaleError.test.ts b/lambdas/functions/control-plane/src/scale-runners/ScaleError.test.ts index 8490a80447..ca19c1722a 100644 --- a/lambdas/functions/control-plane/src/scale-runners/ScaleError.test.ts +++ b/lambdas/functions/control-plane/src/scale-runners/ScaleError.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import type { ActionRequestMessageSQS } from './scale-up'; +import type { ActionRequestMessageSQS } from './types'; import ScaleError from './ScaleError'; describe('ScaleError', () => { diff --git a/lambdas/functions/control-plane/src/scale-runners/ScaleError.ts b/lambdas/functions/control-plane/src/scale-runners/ScaleError.ts index 9c1f474d17..226f635eb5 100644 --- a/lambdas/functions/control-plane/src/scale-runners/ScaleError.ts +++ b/lambdas/functions/control-plane/src/scale-runners/ScaleError.ts @@ -1,5 +1,5 @@ import type { SQSBatchItemFailure } from 'aws-lambda'; -import type { ActionRequestMessageSQS } from './scale-up'; +import type { ActionRequestMessageSQS } from './types'; class ScaleError extends Error { constructor(public readonly failedInstanceCount: number = 1) { diff --git a/lambdas/functions/control-plane/src/scale-runners/ec2-labels.ts b/lambdas/functions/control-plane/src/scale-runners/ec2-labels.ts new file mode 100644 index 0000000000..496b7dab46 --- /dev/null +++ b/lambdas/functions/control-plane/src/scale-runners/ec2-labels.ts @@ -0,0 +1,388 @@ +import { + _InstanceType, + AcceleratorCountRequest, + AcceleratorManufacturer, + AcceleratorName, + AcceleratorTotalMemoryMiBRequest, + AcceleratorType, + BareMetal, + BaselineEbsBandwidthMbpsRequest, + BaselinePerformanceFactorsRequest, + BurstablePerformance, + CpuManufacturer, + CpuPerformanceFactorRequest, + DescribeLaunchTemplateVersionsCommand, + EC2Client, + FleetBlockDeviceMappingRequest, + FleetEbsBlockDeviceRequest, + InstanceGeneration, + InstanceRequirementsRequest, + LocalStorage, + LocalStorageType, + MemoryGiBPerVCpuRequest, + MemoryMiBRequest, + NetworkBandwidthGbpsRequest, + NetworkInterfaceCountRequest, + PerformanceFactorReferenceRequest, + Placement, + Tenancy, + TotalLocalStorageGBRequest, + VCpuCountRangeRequest, + VolumeType, +} from '@aws-sdk/client-ec2'; +import { getTracedAWSV3Client } from '@aws-github-runner/aws-powertools-util'; + +import { Ec2OverrideConfig } from './../aws/ec2-runners.d'; + +const EC2_OVERRIDE_LIST_VALUE_SEPARATOR = ';'; + +/** + * Parses EC2 override configuration from GitHub labels. + * + * Supported label formats: + * + * Basic Fleet Overrides: + * - ghr-ec2-instance-type: - Set specific instance type (e.g., c5.xlarge) + * - ghr-ec2-max-price: - Set maximum spot price + * - ghr-ec2-subnet-id: - Set subnet ID + * - ghr-ec2-availability-zone: - Set availability zone + * - ghr-ec2-availability-zone-id: - Set availability zone ID + * - ghr-ec2-weighted-capacity: - Set weighted capacity + * - ghr-ec2-priority: - Set launch priority + * - ghr-ec2-image-id: - Override AMI ID + * + * Instance Requirements (vCPU & Memory): + * - ghr-ec2-vcpu-count-min: - Set minimum vCPU count + * - ghr-ec2-vcpu-count-max: - Set maximum vCPU count + * - ghr-ec2-memory-mib-min: - Set minimum memory in MiB + * - ghr-ec2-memory-mib-max: - Set maximum memory in MiB + * - ghr-ec2-memory-gib-per-vcpu-min: - Set min memory per vCPU ratio + * - ghr-ec2-memory-gib-per-vcpu-max: - Set max memory per vCPU ratio + * + * Instance Requirements (CPU & Performance): + * - ghr-ec2-cpu-manufacturers: - CPU manufacturers (semicolon-separated: intel;amd;amazon-web-services) + * - ghr-ec2-instance-generations: - Instance generations (semicolon-separated: current;previous) + * - ghr-ec2-excluded-instance-types: - Exclude instance types (semicolon-separated) + * - ghr-ec2-allowed-instance-types: - Allow only specific instance types (semicolon-separated) + * - ghr-ec2-burstable-performance: - Burstable performance (included,excluded,required) + * - ghr-ec2-bare-metal: - Bare metal (included,excluded,required) + * + * Instance Requirements (Accelerators/GPU): + * - ghr-ec2-accelerator-types: - Accelerator types (semicolon-separated: gpu;fpga;inference) + * - ghr-ec2-accelerator-count-min: - Set minimum accelerator count + * - ghr-ec2-accelerator-count-max: - Set maximum accelerator count + * - ghr-ec2-accelerator-manufacturers: - Accelerator manufacturers (semicolon-separated: nvidia;amd;amazon-web-services;xilinx) + * - ghr-ec2-accelerator-names: - Specific accelerator names (semicolon-separated) + * - ghr-ec2-accelerator-total-memory-mib-min: - Min accelerator total memory in MiB + * - ghr-ec2-accelerator-total-memory-mib-max: - Max accelerator total memory in MiB + * + * Instance Requirements (Network & Storage): + * - ghr-ec2-network-interface-count-min: - Min network interfaces + * - ghr-ec2-network-interface-count-max: - Max network interfaces + * - ghr-ec2-network-bandwidth-gbps-min: - Min network bandwidth in Gbps + * - ghr-ec2-network-bandwidth-gbps-max: - Max network bandwidth in Gbps + * - ghr-ec2-local-storage: - Local storage (included,excluded,required) + * - ghr-ec2-local-storage-types: - Local storage types (semicolon-separated: hdd;ssd) + * - ghr-ec2-total-local-storage-gb-min: - Min total local storage in GB + * - ghr-ec2-total-local-storage-gb-max: - Max total local storage in GB + * - ghr-ec2-baseline-ebs-bandwidth-mbps-min: - Min baseline EBS bandwidth in Mbps + * - ghr-ec2-baseline-ebs-bandwidth-mbps-max: - Max baseline EBS bandwidth in Mbps + * + * Placement: + * - ghr-ec2-placement-group-name: - Placement group name + * - ghr-ec2-placement-group-id: - Placement group ID + * - ghr-ec2-placement-tenancy: - Tenancy (default,dedicated,host) + * - ghr-ec2-placement-host-id: - Dedicated host ID + * - ghr-ec2-placement-affinity: - Affinity (default,host) + * - ghr-ec2-placement-partition-number: - Partition number + * - ghr-ec2-placement-availability-zone: - Placement availability zone + * - ghr-ec2-placement-availability-zone-id: - Placement availability zone ID + * - ghr-ec2-placement-spread-domain: - Spread domain + * - ghr-ec2-placement-host-resource-group-arn: - Host resource group ARN + * + * Block Device Mappings: + * - ghr-ec2-block-device-name: - Block device name + * - ghr-ec2-ebs-volume-size: - EBS volume size in GB + * - ghr-ec2-ebs-volume-type: - EBS volume type (gp2,gp3,io1,io2,st1,sc1) + * - ghr-ec2-ebs-iops: - EBS IOPS + * - ghr-ec2-ebs-throughput: - EBS throughput in MB/s (gp3 only) + * - ghr-ec2-ebs-encrypted: - EBS encryption (true,false) + * - ghr-ec2-ebs-kms-key-id: - KMS key ID for encryption + * - ghr-ec2-ebs-delete-on-termination: - Delete on termination (true,false) + * - ghr-ec2-ebs-snapshot-id: - Snapshot ID for EBS volume + * - ghr-ec2-block-device-virtual-name: - Virtual device name (ephemeral storage) + * - ghr-ec2-block-device-no-device: - Suppresses device mapping + * + * Pricing & Advanced: + * - ghr-ec2-spot-max-price-percentage-over-lowest-price: - Spot max price as % over lowest price + * - ghr-ec2-on-demand-max-price-percentage-over-lowest-price: - On-demand max price as % over lowest price + * - ghr-ec2-max-spot-price-as-percentage-of-optimal-on-demand-price: - Max spot price as % of optimal on-demand + * - ghr-ec2-require-hibernate-support: - Require hibernate support (true,false) + * - ghr-ec2-require-encryption-in-transit: - Require encryption in-transit (true,false) + * - ghr-ec2-baseline-performance-factors-cpu-reference-families: - CPU baseline performance reference families (semicolon-separated) + * + * Example: + * runs-on: [self-hosted, linux, ghr-ec2-vcpu-count-min:4, ghr-ec2-memory-mib-min:16384, ghr-ec2-accelerator-types:gpu] + * + * @param labels - Array of GitHub workflow job labels + * @param defaultBlockDeviceName - Device name to use when dynamic block device labels create a mapping + * @returns EC2 override configuration object or undefined if no valid config found + */ +export function parseEc2OverrideConfig( + labels: string[], + defaultBlockDeviceName?: string, +): Ec2OverrideConfig | undefined { + const ec2Labels = labels.filter((l) => l.startsWith('ghr-ec2-')); + const config: Ec2OverrideConfig = {}; + + for (const label of ec2Labels) { + const [key, ...valueParts] = label.replace('ghr-ec2-', '').split(':'); + const value = valueParts.join(':'); + + if (!value) continue; + + // Basic Fleet Overrides + if (key === 'instance-type') { + config.InstanceType = value as _InstanceType; + } else if (key === 'subnet-id') { + config.SubnetId = value; + } else if (key === 'availability-zone') { + config.AvailabilityZone = value; + } else if (key === 'availability-zone-id') { + config.AvailabilityZoneId = value; + } else if (key === 'max-price') { + config.MaxPrice = value; + } else if (key === 'priority') { + config.Priority = parseFloat(value); + } else if (key === 'weighted-capacity') { + config.WeightedCapacity = parseFloat(value); + } else if (key === 'image-id') { + config.ImageId = value; + } + + // Placement + else if (key.startsWith('placement-')) { + config.Placement = config.Placement || ({} as Placement); + const placementKey = key.replace('placement-', ''); + if (placementKey === 'availability-zone-id') { + config.Placement.AvailabilityZoneId = value; + } else if (placementKey === 'affinity') { + config.Placement.Affinity = value; + } else if (placementKey === 'group-name') { + config.Placement.GroupName = value; + } else if (placementKey === 'partition-number') { + config.Placement.PartitionNumber = parseInt(value, 10); + } else if (placementKey === 'host-id') { + config.Placement.HostId = value; + } else if (placementKey === 'tenancy') { + config.Placement.Tenancy = value as Tenancy; + } else if (placementKey === 'spread-domain') { + config.Placement.SpreadDomain = value; + } else if (placementKey === 'host-resource-group-arn') { + config.Placement.HostResourceGroupArn = value; + } else if (placementKey === 'group-id') { + config.Placement.GroupId = value; + } else if (placementKey === 'availability-zone') { + config.Placement.AvailabilityZone = value; + } + } + + // Block Device Mappings + else if (key === 'block-device-name') { + getOrCreateBlockDeviceMapping(config, defaultBlockDeviceName).DeviceName = value; + } else if (key === 'block-device-virtual-name') { + getOrCreateBlockDeviceMapping(config, defaultBlockDeviceName).VirtualName = value; + } else if (key.startsWith('ebs-')) { + const blockDeviceMapping = getOrCreateBlockDeviceMapping(config, defaultBlockDeviceName); + const ebsKey = key.replace('ebs-', ''); + const ebs = blockDeviceMapping.Ebs || (blockDeviceMapping.Ebs = {} as FleetEbsBlockDeviceRequest); + + if (ebsKey === 'encrypted') { + ebs.Encrypted = value.toLowerCase() === 'true'; + } else if (ebsKey === 'delete-on-termination') { + ebs.DeleteOnTermination = value.toLowerCase() === 'true'; + } else if (ebsKey === 'iops') { + ebs.Iops = parseInt(value, 10); + } else if (ebsKey === 'throughput') { + ebs.Throughput = parseInt(value, 10); + } else if (ebsKey === 'kms-key-id') { + ebs.KmsKeyId = value; + } else if (ebsKey === 'snapshot-id') { + ebs.SnapshotId = value; + } else if (ebsKey === 'volume-size') { + ebs.VolumeSize = parseInt(value, 10); + } else if (ebsKey === 'volume-type') { + ebs.VolumeType = value as VolumeType; + } + } else if (key === 'block-device-no-device') { + getOrCreateBlockDeviceMapping(config, defaultBlockDeviceName).NoDevice = value; + } + + // Instance Requirements + else if (key.startsWith('vcpu-count-')) { + config.InstanceRequirements = config.InstanceRequirements || ({} as InstanceRequirementsRequest); + config.InstanceRequirements.VCpuCount = config.InstanceRequirements.VCpuCount || ({} as VCpuCountRangeRequest); + const subKey = key.replace('vcpu-count-', ''); + config.InstanceRequirements.VCpuCount![subKey === 'min' ? 'Min' : 'Max'] = parseInt(value, 10); + } else if (key.startsWith('memory-mib-')) { + config.InstanceRequirements = config.InstanceRequirements || ({} as InstanceRequirementsRequest); + config.InstanceRequirements.MemoryMiB = config.InstanceRequirements.MemoryMiB || ({} as MemoryMiBRequest); + const subKey = key.replace('memory-mib-', ''); + config.InstanceRequirements.MemoryMiB![subKey === 'min' ? 'Min' : 'Max'] = parseInt(value, 10); + } else if (key === 'cpu-manufacturers') { + config.InstanceRequirements = config.InstanceRequirements || ({} as InstanceRequirementsRequest); + config.InstanceRequirements.CpuManufacturers = splitEc2OverrideListValue(value) as CpuManufacturer[]; + } else if (key.startsWith('memory-gib-per-vcpu-')) { + config.InstanceRequirements = config.InstanceRequirements || ({} as InstanceRequirementsRequest); + config.InstanceRequirements.MemoryGiBPerVCpu = + config.InstanceRequirements.MemoryGiBPerVCpu || ({} as MemoryGiBPerVCpuRequest); + const subKey = key.replace('memory-gib-per-vcpu-', ''); + config.InstanceRequirements.MemoryGiBPerVCpu![subKey === 'min' ? 'Min' : 'Max'] = parseFloat(value); + } else if (key === 'excluded-instance-types') { + config.InstanceRequirements = config.InstanceRequirements || ({} as InstanceRequirementsRequest); + config.InstanceRequirements.ExcludedInstanceTypes = splitEc2OverrideListValue(value); + } else if (key === 'instance-generations') { + config.InstanceRequirements = config.InstanceRequirements || ({} as InstanceRequirementsRequest); + config.InstanceRequirements.InstanceGenerations = splitEc2OverrideListValue(value) as InstanceGeneration[]; + } else if (key === 'spot-max-price-percentage-over-lowest-price') { + config.InstanceRequirements = config.InstanceRequirements || ({} as InstanceRequirementsRequest); + config.InstanceRequirements.SpotMaxPricePercentageOverLowestPrice = parseInt(value, 10); + } else if (key === 'on-demand-max-price-percentage-over-lowest-price') { + config.InstanceRequirements = config.InstanceRequirements || ({} as InstanceRequirementsRequest); + config.InstanceRequirements.OnDemandMaxPricePercentageOverLowestPrice = parseInt(value, 10); + } else if (key === 'bare-metal') { + config.InstanceRequirements = config.InstanceRequirements || ({} as InstanceRequirementsRequest); + config.InstanceRequirements.BareMetal = value as BareMetal; + } else if (key === 'burstable-performance') { + config.InstanceRequirements = config.InstanceRequirements || ({} as InstanceRequirementsRequest); + config.InstanceRequirements.BurstablePerformance = value as BurstablePerformance; + } else if (key === 'require-hibernate-support') { + config.InstanceRequirements = config.InstanceRequirements || ({} as InstanceRequirementsRequest); + config.InstanceRequirements.RequireHibernateSupport = value.toLowerCase() === 'true'; + } else if (key.startsWith('network-interface-count-')) { + config.InstanceRequirements = config.InstanceRequirements || ({} as InstanceRequirementsRequest); + config.InstanceRequirements.NetworkInterfaceCount = + config.InstanceRequirements.NetworkInterfaceCount || ({} as NetworkInterfaceCountRequest); + const subKey = key.replace('network-interface-count-', ''); + config.InstanceRequirements.NetworkInterfaceCount![subKey === 'min' ? 'Min' : 'Max'] = parseInt(value, 10); + } else if (key === 'local-storage') { + config.InstanceRequirements = config.InstanceRequirements || ({} as InstanceRequirementsRequest); + config.InstanceRequirements.LocalStorage = value as LocalStorage; + } else if (key === 'local-storage-types') { + config.InstanceRequirements = config.InstanceRequirements || ({} as InstanceRequirementsRequest); + config.InstanceRequirements.LocalStorageTypes = splitEc2OverrideListValue(value) as LocalStorageType[]; + } else if (key.startsWith('total-local-storage-gb-')) { + config.InstanceRequirements = config.InstanceRequirements || ({} as InstanceRequirementsRequest); + config.InstanceRequirements.TotalLocalStorageGB = + config.InstanceRequirements.TotalLocalStorageGB || ({} as TotalLocalStorageGBRequest); + const subKey = key.replace('total-local-storage-gb-', ''); + config.InstanceRequirements.TotalLocalStorageGB![subKey === 'min' ? 'Min' : 'Max'] = parseFloat(value); + } else if (key.startsWith('baseline-ebs-bandwidth-mbps-')) { + config.InstanceRequirements = config.InstanceRequirements || ({} as InstanceRequirementsRequest); + config.InstanceRequirements.BaselineEbsBandwidthMbps = + config.InstanceRequirements.BaselineEbsBandwidthMbps || ({} as BaselineEbsBandwidthMbpsRequest); + const subKey = key.replace('baseline-ebs-bandwidth-mbps-', ''); + config.InstanceRequirements.BaselineEbsBandwidthMbps![subKey === 'min' ? 'Min' : 'Max'] = parseInt(value, 10); + } else if (key === 'accelerator-types') { + config.InstanceRequirements = config.InstanceRequirements || ({} as InstanceRequirementsRequest); + config.InstanceRequirements.AcceleratorTypes = splitEc2OverrideListValue(value) as AcceleratorType[]; + } else if (key.startsWith('accelerator-count-')) { + config.InstanceRequirements = config.InstanceRequirements || ({} as InstanceRequirementsRequest); + config.InstanceRequirements.AcceleratorCount = + config.InstanceRequirements.AcceleratorCount || ({} as AcceleratorCountRequest); + const subKey = key.replace('accelerator-count-', ''); + config.InstanceRequirements.AcceleratorCount![subKey === 'min' ? 'Min' : 'Max'] = parseInt(value, 10); + } else if (key === 'accelerator-manufacturers') { + config.InstanceRequirements = config.InstanceRequirements || ({} as InstanceRequirementsRequest); + config.InstanceRequirements.AcceleratorManufacturers = splitEc2OverrideListValue( + value, + ) as AcceleratorManufacturer[]; + } else if (key === 'accelerator-names') { + config.InstanceRequirements = config.InstanceRequirements || ({} as InstanceRequirementsRequest); + config.InstanceRequirements.AcceleratorNames = splitEc2OverrideListValue(value) as AcceleratorName[]; + } else if (key.startsWith('accelerator-total-memory-mib-')) { + config.InstanceRequirements = config.InstanceRequirements || ({} as InstanceRequirementsRequest); + config.InstanceRequirements.AcceleratorTotalMemoryMiB = + config.InstanceRequirements.AcceleratorTotalMemoryMiB || ({} as AcceleratorTotalMemoryMiBRequest); + const subKey = key.replace('accelerator-total-memory-mib-', ''); + config.InstanceRequirements.AcceleratorTotalMemoryMiB![subKey === 'min' ? 'Min' : 'Max'] = parseInt(value, 10); + } else if (key.startsWith('network-bandwidth-gbps-')) { + config.InstanceRequirements = config.InstanceRequirements || ({} as InstanceRequirementsRequest); + config.InstanceRequirements.NetworkBandwidthGbps = + config.InstanceRequirements.NetworkBandwidthGbps || ({} as NetworkBandwidthGbpsRequest); + const subKey = key.replace('network-bandwidth-gbps-', ''); + config.InstanceRequirements.NetworkBandwidthGbps![subKey === 'min' ? 'Min' : 'Max'] = parseFloat(value); + } else if (key === 'allowed-instance-types') { + config.InstanceRequirements = config.InstanceRequirements || ({} as InstanceRequirementsRequest); + config.InstanceRequirements.AllowedInstanceTypes = splitEc2OverrideListValue(value); + } else if (key === 'max-spot-price-as-percentage-of-optimal-on-demand-price') { + config.InstanceRequirements = config.InstanceRequirements || ({} as InstanceRequirementsRequest); + config.InstanceRequirements.MaxSpotPriceAsPercentageOfOptimalOnDemandPrice = parseInt(value, 10); + } else if (key === 'baseline-performance-factors-cpu-reference-families') { + config.InstanceRequirements = config.InstanceRequirements || ({} as InstanceRequirementsRequest); + config.InstanceRequirements.BaselinePerformanceFactors = + config.InstanceRequirements.BaselinePerformanceFactors || ({} as BaselinePerformanceFactorsRequest); + config.InstanceRequirements.BaselinePerformanceFactors.Cpu = + config.InstanceRequirements.BaselinePerformanceFactors.Cpu || ({} as CpuPerformanceFactorRequest); + config.InstanceRequirements.BaselinePerformanceFactors.Cpu.References = splitEc2OverrideListValue(value).map( + (family) => ({ InstanceFamily: family }), + ) as PerformanceFactorReferenceRequest[]; + } else if (key === 'require-encryption-in-transit') { + config.InstanceRequirements = config.InstanceRequirements || ({} as InstanceRequirementsRequest); + config.InstanceRequirements.RequireEncryptionInTransit = value.toLowerCase() === 'true'; + } + } + + return Object.keys(config).length > 0 ? config : undefined; +} + +function splitEc2OverrideListValue(value: string): string[] { + return value.split(EC2_OVERRIDE_LIST_VALUE_SEPARATOR); +} + +function getOrCreateBlockDeviceMapping( + config: Ec2OverrideConfig, + defaultBlockDeviceName?: string, +): FleetBlockDeviceMappingRequest { + config.BlockDeviceMappings = + config.BlockDeviceMappings || + ([defaultBlockDeviceName ? { DeviceName: defaultBlockDeviceName } : {}] as FleetBlockDeviceMappingRequest[]); + return config.BlockDeviceMappings[0]; +} + +export function shouldLoadLaunchTemplateBlockDeviceName(labels: string[]): boolean { + const blockDeviceNameLabel = 'ghr-ec2-block-device-name:'; + let hasBlockDeviceOverride = false; + let hasBlockDeviceName = false; + + for (const label of labels) { + hasBlockDeviceOverride = + hasBlockDeviceOverride || label.startsWith('ghr-ec2-ebs-') || label.startsWith('ghr-ec2-block-device-'); + + hasBlockDeviceName = + hasBlockDeviceName || (label.startsWith(blockDeviceNameLabel) && label.slice(blockDeviceNameLabel.length) !== ''); + } + + return hasBlockDeviceOverride && !hasBlockDeviceName; +} + +export async function getDefaultBlockDeviceNameFromLaunchTemplate(launchTemplateName: string): Promise { + const ec2Client = getTracedAWSV3Client(new EC2Client({ region: process.env.AWS_REGION })); + const launchTemplateVersions = await ec2Client.send( + new DescribeLaunchTemplateVersionsCommand({ + LaunchTemplateName: launchTemplateName, + Versions: ['$Default'], + }), + ); + const blockDeviceMappings = + launchTemplateVersions.LaunchTemplateVersions?.[0]?.LaunchTemplateData?.BlockDeviceMappings; + const blockDeviceName = + blockDeviceMappings?.find((blockDeviceMapping) => blockDeviceMapping.DeviceName && blockDeviceMapping.Ebs) + ?.DeviceName ?? blockDeviceMappings?.find((blockDeviceMapping) => blockDeviceMapping.DeviceName)?.DeviceName; + + if (!blockDeviceName) { + throw new Error(`Failed to determine block device name from launch template '${launchTemplateName}'.`); + } + + return blockDeviceName; +} diff --git a/lambdas/functions/control-plane/src/scale-runners/ec2-scale-down.ts b/lambdas/functions/control-plane/src/scale-runners/ec2-scale-down.ts new file mode 100644 index 0000000000..554ea375b7 --- /dev/null +++ b/lambdas/functions/control-plane/src/scale-runners/ec2-scale-down.ts @@ -0,0 +1,39 @@ +import { bootTimeExceeded, listEC2Runners, tag, terminateRunner, untag } from './../aws/ec2-runners'; +import type { RunnerList } from './../aws/ec2-runners.d'; +import type { RunnerList as ScaleDownRunnerList, ScaleDownRunnerProvider } from './scale-down-provider'; + +async function listEc2ScaleDownRunners(environment: string, orphan?: boolean): Promise { + return (await listEC2Runners({ environment, orphan })).map(toScaleDownRunner); +} + +async function markEc2RunnerOrphan(id: string): Promise { + await tag(id, [{ Key: 'ghr:orphan', Value: 'true' }]); +} + +async function unmarkEc2RunnerOrphan(id: string): Promise { + await untag(id, [{ Key: 'ghr:orphan', Value: 'true' }]); +} + +export function createEc2ScaleDownProvider(): Omit { + return { + list: listEc2ScaleDownRunners, + bootTimeExceeded, + markOrphan: markEc2RunnerOrphan, + unmarkOrphan: unmarkEc2RunnerOrphan, + terminate: terminateRunner, + }; +} + +function toScaleDownRunner(runner: RunnerList): ScaleDownRunnerList { + return { + id: runner.instanceId, + launchTime: runner.launchTime, + owner: runner.owner, + type: runner.type, + repo: runner.repo, + org: runner.org, + orphan: runner.orphan, + githubRunnerId: runner.runnerId, + bypassRemoval: runner.bypassRemoval, + }; +} diff --git a/lambdas/functions/control-plane/src/scale-runners/ec2-scale-up.ts b/lambdas/functions/control-plane/src/scale-runners/ec2-scale-up.ts new file mode 100644 index 0000000000..b79761f2a2 --- /dev/null +++ b/lambdas/functions/control-plane/src/scale-runners/ec2-scale-up.ts @@ -0,0 +1,108 @@ +import { createChildLogger } from '@aws-github-runner/aws-powertools-util'; +import yn from 'yn'; + +import { listEC2Runners } from '../aws/ec2-runners'; +import type { Ec2OverrideConfig } from './../aws/ec2-runners.d'; +import { + getDefaultBlockDeviceNameFromLaunchTemplate, + parseEc2OverrideConfig, + shouldLoadLaunchTemplateBlockDeviceName, +} from './ec2-labels'; +import { createRunners } from './ec2'; +import type { CreateEC2RunnerConfig } from './ec2'; +import type { + CreateScaleUpRunnersInput, + CurrentRunnersInput, + PreparedScaleUpRunnerGroup, + ScaleUpRunnerProvider, +} from './scale-up-provider'; + +const logger = createChildLogger('ec2-scale-up'); + +type Ec2ScaleUpProviderConfig = Omit; + +interface Ec2ScaleUpState { + ec2OverrideConfig?: Ec2OverrideConfig; +} + +function loadEc2ScaleUpProviderConfig(): Ec2ScaleUpProviderConfig { + return { + ec2instanceCriteria: { + instanceTypes: process.env.INSTANCE_TYPES.split(','), + instanceTypePriorities: process.env.INSTANCE_TYPE_PRIORITIES + ? (JSON.parse(process.env.INSTANCE_TYPE_PRIORITIES) as Record) + : undefined, + targetCapacityType: process.env.INSTANCE_TARGET_CAPACITY_TYPE, + maxSpotPrice: process.env.INSTANCE_MAX_SPOT_PRICE, + instanceAllocationStrategy: process.env.INSTANCE_ALLOCATION_STRATEGY || 'lowest-price', + }, + environment: process.env.ENVIRONMENT, + launchTemplateName: process.env.LAUNCH_TEMPLATE_NAME, + subnets: process.env.SUBNET_IDS.split(','), + amiIdSsmParameterName: process.env.AMI_ID_SSM_PARAMETER_NAME, + tracingEnabled: yn(process.env.POWERTOOLS_TRACE_ENABLED, { default: false }), + onDemandFailoverOnError: process.env.ENABLE_ON_DEMAND_FAILOVER_FOR_ERRORS + ? (JSON.parse(process.env.ENABLE_ON_DEMAND_FAILOVER_FOR_ERRORS) as [string]) + : [], + scaleErrors: JSON.parse(process.env.SCALE_ERRORS) as [string], + useDedicatedHost: yn(process.env.USE_DEDICATED_HOST, { default: false }), + }; +} + +async function prepareEc2ScaleUpGroup(messageLabels: string[]): Promise> { + const trimmedLabels = messageLabels.map((label) => label.trim()); + const dynamicEC2Labels = trimmedLabels.filter((label) => label.startsWith('ghr-ec2-')); + const nonEc2DynamicLabels = trimmedLabels.filter( + (label) => label.startsWith('ghr-') && !label.startsWith('ghr-ec2-'), + ); + const runnerLabels = [...nonEc2DynamicLabels, ...dynamicEC2Labels]; + let ec2OverrideConfig: Ec2OverrideConfig | undefined; + + if (dynamicEC2Labels.length > 0) { + const defaultBlockDeviceName = shouldLoadLaunchTemplateBlockDeviceName(dynamicEC2Labels) + ? await getDefaultBlockDeviceNameFromLaunchTemplate(process.env.LAUNCH_TEMPLATE_NAME) + : undefined; + + ec2OverrideConfig = parseEc2OverrideConfig(dynamicEC2Labels, defaultBlockDeviceName); + if (ec2OverrideConfig) { + logger.debug('EC2 override config parsed from labels', { ec2OverrideConfig }); + } + } + + return { runnerLabels, state: { ec2OverrideConfig } }; +} + +async function getCurrentEc2Runners( + _state: Ec2ScaleUpState, + { runnerType, runnerOwner }: CurrentRunnersInput, +): Promise { + return (await listEC2Runners({ environment: process.env.ENVIRONMENT, runnerType, runnerOwner })).length; +} + +async function createEc2ScaleUpRunners({ + githubRunnerConfig, + numberOfRunners, + githubInstallationClient, + state, +}: CreateScaleUpRunnersInput): Promise { + const config = loadEc2ScaleUpProviderConfig(); + + return await createRunners( + githubRunnerConfig, + { + ...config, + ec2OverrideConfig: state.ec2OverrideConfig, + }, + numberOfRunners, + githubInstallationClient, + 'scale-up-lambda', + ); +} + +export function createEc2ScaleUpProvider(): Omit, 'type'> { + return { + prepareGroup: prepareEc2ScaleUpGroup, + getCurrentRunners: getCurrentEc2Runners, + createRunners: createEc2ScaleUpRunners, + }; +} diff --git a/lambdas/functions/control-plane/src/scale-runners/ec2.ts b/lambdas/functions/control-plane/src/scale-runners/ec2.ts new file mode 100644 index 0000000000..b970b77b2f --- /dev/null +++ b/lambdas/functions/control-plane/src/scale-runners/ec2.ts @@ -0,0 +1,129 @@ +import { createChildLogger } from '@aws-github-runner/aws-powertools-util'; +import { Octokit } from '@octokit/rest'; +import type { Tag } from '@aws-sdk/client-ec2'; + +import { createRunner, tag, terminateRunner } from '../aws/ec2-runners'; +import type { RunnerInputParameters } from '../aws/ec2-runners.d'; +import { createStartRunnerConfig } from './github-runner'; +import type { GitHubRunnerMetadata, StartRunnerConfigOptions } from './github-runner'; +import type { CreateGitHubRunnerConfig, LambdaRunnerSource } from './types'; + +const logger = createChildLogger('ec2-scale-up'); +const RUNNER_LABELS_TAG_KEY = 'ghr:runner_labels'; +const RUNNER_LABELS_TAG_VALUE_SEPARATOR = ','; +export const EC2_TAG_VALUE_MAX_LENGTH = 256; +export const RUNNER_LABELS_TAG_MAX_COUNT = 5; + +export interface CreateEC2RunnerConfig { + environment: string; + subnets: string[]; + launchTemplateName: string; + ec2instanceCriteria: RunnerInputParameters['ec2instanceCriteria']; + ec2OverrideConfig?: RunnerInputParameters['ec2OverrideConfig']; + numberOfRunners?: number; + amiIdSsmParameterName?: string; + tracingEnabled?: boolean; + onDemandFailoverOnError?: string[]; + scaleErrors: string[]; + useDedicatedHost?: boolean; +} + +export async function createRunners( + githubRunnerConfig: CreateGitHubRunnerConfig, + ec2RunnerConfig: CreateEC2RunnerConfig, + numberOfRunners: number, + ghClient: Octokit, + source: LambdaRunnerSource = 'scale-up-lambda', +): Promise { + const instances = await createRunner({ + runnerType: githubRunnerConfig.runnerType, + runnerOwner: githubRunnerConfig.runnerOwner, + numberOfRunners, + source, + ...ec2RunnerConfig, + }); + if (instances.length !== 0) { + const failedInstances = await createStartRunnerConfig( + githubRunnerConfig, + instances, + ghClient, + createEc2StartRunnerConfigOptions(), + ); + + // Terminate instances that failed to get configured to avoid waste + if (failedInstances.length > 0) { + logger.warn('Terminating instances that failed to get configured', { + failedInstances, + failedCount: failedInstances.length, + }); + + for (const instanceId of failedInstances) { + try { + await terminateRunner(instanceId); + } catch (error) { + logger.error('Failed to terminate instance', { + instanceId, + error: error instanceof Error ? error.message : String(error), + }); + } + } + + // Remove failed instances from the returned list + return instances.filter((id) => !failedInstances.includes(id)); + } + } + + return instances; +} + +function createEc2StartRunnerConfigOptions(): StartRunnerConfigOptions { + return { + getSsmParameterTags: (instanceId) => [{ Key: 'InstanceId', Value: instanceId }], + onJitConfigCreated: async (instanceId, metadata) => await tagEc2RunnerMetadata(instanceId, metadata), + }; +} + +async function tagEc2RunnerMetadata(instanceId: string, metadata: GitHubRunnerMetadata): Promise { + const tags = [ + { Key: 'ghr:github_runner_id', Value: metadata.githubRunnerId }, + ...generateRunnerLabelsTags(metadata.runnerLabels), + ]; + + try { + await tag(instanceId, tags); + } catch (e) { + logger.error(`Failed to mark EC2 runner '${instanceId}' with GitHub runner metadata.`, { error: e }); + } +} + +function generateRunnerLabelsTags(labels: string[]): Tag[] { + if (labels.length === 0) { + return []; + } + + const generatedTagValues = packRunnerLabelsTagValues(labels); + const tagValues = generatedTagValues.slice(0, RUNNER_LABELS_TAG_MAX_COUNT); + + if (generatedTagValues.length > RUNNER_LABELS_TAG_MAX_COUNT) { + logger.warn('GitHub runner label EC2 tags were truncated to avoid exceeding EC2 tag limits.', { + maxRunnerLabelsTagCount: RUNNER_LABELS_TAG_MAX_COUNT, + }); + } + + return tagValues.map((value, index) => ({ + Key: index === 0 ? RUNNER_LABELS_TAG_KEY : `${RUNNER_LABELS_TAG_KEY}:${index + 1}`, + Value: value, + })); +} + +function packRunnerLabelsTagValues(labels: string[]): string[] { + const runnerLabelsValue = labels.join(RUNNER_LABELS_TAG_VALUE_SEPARATOR); + const characters = Array.from(runnerLabelsValue); + const tagValues: string[] = []; + + for (let start = 0; start < characters.length; start += EC2_TAG_VALUE_MAX_LENGTH) { + tagValues.push(characters.slice(start, start + EC2_TAG_VALUE_MAX_LENGTH).join('')); + } + + return tagValues; +} diff --git a/lambdas/functions/control-plane/src/scale-runners/github-runner.ts b/lambdas/functions/control-plane/src/scale-runners/github-runner.ts new file mode 100644 index 0000000000..0b9ea2af51 --- /dev/null +++ b/lambdas/functions/control-plane/src/scale-runners/github-runner.ts @@ -0,0 +1,371 @@ +import { createChildLogger } from '@aws-github-runner/aws-powertools-util'; +import { getParameter, putParameter } from '@aws-github-runner/aws-ssm-util'; +import { Octokit } from '@octokit/rest'; + +import { metricGitHubAppRateLimit } from '../github/rate-limit'; +import { ActionRequestMessage, CreateGitHubRunnerConfig, EphemeralRunnerConfig, RunnerGroup } from './types'; + +const logger = createChildLogger('github-runner'); + +export interface GitHubRunnerMetadata { + githubRunnerId: string; + runnerLabels: string[]; +} + +export interface StartRunnerConfigOptions { + getSsmParameterTags?: (runnerId: string) => { Key: string; Value: string }[]; + onJitConfigCreated?: (runnerId: string, metadata: GitHubRunnerMetadata) => Promise; +} + +function generateRunnerServiceConfig(githubRunnerConfig: CreateGitHubRunnerConfig, token: string) { + const config = [ + `--url ${githubRunnerConfig.ghesBaseUrl ?? 'https://github.com'}/${githubRunnerConfig.runnerOwner}`, + `--token ${token}`, + ]; + + if (githubRunnerConfig.runnerLabels) { + config.push(`--labels ${quoteRunnerLabelsForShell(githubRunnerConfig.runnerLabels)}`.trim()); + } + + if (githubRunnerConfig.disableAutoUpdate) { + config.push('--disableupdate'); + } + + if (githubRunnerConfig.runnerType === 'Org' && githubRunnerConfig.runnerGroup !== undefined) { + config.push(`--runnergroup ${githubRunnerConfig.runnerGroup}`); + } + + if (githubRunnerConfig.ephemeral) { + config.push(`--ephemeral`); + } + + return config; +} + +function quoteRunnerLabelsForShell(labels: string): string { + return /[\s;&|<>()$`"'*?[\\\]{}!]/.test(labels) ? quoteShellArg(labels) : labels; +} + +function quoteShellArg(value: string): string { + return `'${value.replace(/'/g, `'\\''`)}'`; +} + +export function validateSsmParameterStoreTags(tagsJson: string): { Key: string; Value: string }[] { + try { + const tags = JSON.parse(tagsJson); + + if (!Array.isArray(tags)) { + throw new Error('Tags must be an array'); + } + + if (tags.length === 0) { + return []; + } + + tags.forEach((tag, index) => { + if (typeof tag !== 'object' || tag === null) { + throw new Error(`Tag at index ${index} must be an object`); + } + if (!tag.Key || typeof tag.Key !== 'string' || tag.Key.trim() === '') { + throw new Error(`Tag at index ${index} has missing or invalid 'Key' property`); + } + if (!Object.prototype.hasOwnProperty.call(tag, 'Value') || typeof tag.Value !== 'string') { + throw new Error(`Tag at index ${index} has missing or invalid 'Value' property`); + } + }); + + return tags; + } catch (err) { + logger.error('Invalid SSM_PARAMETER_STORE_TAGS format', { error: err }); + throw new Error(`Failed to parse SSM_PARAMETER_STORE_TAGS: ${(err as Error).message}`); + } +} + +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], + }); + + return registrationToken.data.token; +} + +function removeTokenFromLogging(config: string[]): string[] { + const result: string[] = []; + config.forEach((e) => { + if (e.startsWith('--token')) { + result.push('--token '); + } else { + result.push(e); + } + }); + return result; +} + +export async function resolveInstallationId( + githubAppClient: Octokit, + enableOrgLevel: boolean, + payload: ActionRequestMessage, +): Promise { + return enableOrgLevel + ? ( + await githubAppClient.apps.getOrgInstallation({ + org: payload.repositoryOwner, + }) + ).data.id + : ( + await githubAppClient.apps.getRepoInstallation({ + owner: payload.repositoryOwner, + repo: payload.repositoryName, + }) + ).data.id; +} + +export async function getInstallationId( + githubAppClient: Octokit, + enableOrgLevel: boolean, + payload: ActionRequestMessage, +): Promise { + if (payload.installationId !== 0) { + return payload.installationId; + } + + return resolveInstallationId(githubAppClient, enableOrgLevel, payload); +} + +export async function isJobQueued(githubInstallationClient: Octokit, payload: ActionRequestMessage): Promise { + let isQueued = false; + if (payload.eventType === 'workflow_job') { + const jobForWorkflowRun = await githubInstallationClient.actions.getJobForWorkflowRun({ + job_id: payload.id, + owner: payload.repositoryOwner, + repo: payload.repositoryName, + }); + metricGitHubAppRateLimit(jobForWorkflowRun.headers); + isQueued = jobForWorkflowRun.data.status === 'queued'; + logger.debug(`The job ${payload.id} is${isQueued ? ' ' : 'not'} queued`); + } else { + throw Error(`Event ${payload.eventType} is not supported`); + } + return isQueued; +} + +export 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) { + 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 + try { + runnerGroup = await getParameter( + `${githubRunnerConfig.ssmConfigPath}/runner-group/${githubRunnerConfig.runnerGroup}`, + ); + } catch (err) { + logger.debug('Handling error:', err as Error); + logger.warn( + `SSM Parameter "${githubRunnerConfig.ssmConfigPath}/runner-group/${githubRunnerConfig.runnerGroup}" + for Runner group ${githubRunnerConfig.runnerGroup} does not exist`, + ); + } + if (runnerGroup === undefined) { + // get runner group id from GitHub + runnerGroupId = await getRunnerGroupByName(ghClient, githubRunnerConfig); + // store runner group id in SSM + try { + await putParameter( + `${githubRunnerConfig.ssmConfigPath}/runner-group/${githubRunnerConfig.runnerGroup}`, + runnerGroupId.toString(), + false, + { + tags: githubRunnerConfig.ssmParameterStoreTags, + }, + ); + } catch (err) { + logger.debug('Error storing runner group id in SSM Parameter Store', err as Error); + throw err; + } + } else { + runnerGroupId = parseInt(runnerGroup); + } + } + return runnerGroupId; +} + +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 runnerGroupId = runnerGroups.find((runnerGroup) => runnerGroup.name === githubRunnerConfig.runnerGroup)?.id; + + if (runnerGroupId === undefined) { + throw new Error(`Runner group ${githubRunnerConfig.runnerGroup} does not exist`); + } + + return runnerGroupId; +} + +/** + * Creates the start configuration for runner targets by either generating JIT configs + * or registration tokens. + * + * @returns Array of runner IDs that failed to get configured + */ +export async function createStartRunnerConfig( + githubRunnerConfig: CreateGitHubRunnerConfig, + runnerIds: string[], + ghClient: Octokit, + options: StartRunnerConfigOptions = {}, +): Promise { + if (githubRunnerConfig.enableJitConfig && githubRunnerConfig.ephemeral) { + return await createJitConfig(githubRunnerConfig, runnerIds, ghClient, options); + } else { + return await createRegistrationTokenConfig(githubRunnerConfig, runnerIds, ghClient, options); + } +} + +function addDelay(runnerIds: string[]) { + const delay = async (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + const ssmParameterStoreMaxThroughput = 40; + const isDelay = runnerIds.length >= ssmParameterStoreMaxThroughput; + return { isDelay, delay }; +} + +/** + * Creates registration token configuration for non-ephemeral runners. + * + * @returns Empty array (this configuration method does not have failure cases) + */ +async function createRegistrationTokenConfig( + githubRunnerConfig: CreateGitHubRunnerConfig, + runnerIds: string[], + ghClient: Octokit, + options: StartRunnerConfigOptions, +): Promise { + const { isDelay, delay } = addDelay(runnerIds); + const token = await getGithubRunnerRegistrationToken(githubRunnerConfig, ghClient); + const runnerServiceConfig = generateRunnerServiceConfig(githubRunnerConfig, token); + + logger.debug('Runner service config for non-ephemeral runners', { + runner_service_config: removeTokenFromLogging(runnerServiceConfig), + }); + + for (const runnerId of runnerIds) { + await putParameter(`${githubRunnerConfig.ssmTokenPath}/${runnerId}`, runnerServiceConfig.join(' '), true, { + tags: [...(options.getSsmParameterTags?.(runnerId) ?? []), ...githubRunnerConfig.ssmParameterStoreTags], + }); + if (isDelay) { + // Delay to prevent AWS ssm rate limits by being within the max throughput limit + await delay(25); + } + } + + return []; +} + +/** + * Creates JIT (Just-In-Time) configuration for ephemeral runners. + * Continues processing remaining runners even if some fail. + * + * @returns Array of runner IDs that failed to get JIT configuration + */ +async function createJitConfig( + githubRunnerConfig: CreateGitHubRunnerConfig, + runnerIds: string[], + ghClient: Octokit, + options: StartRunnerConfigOptions, +): Promise { + const runnerGroupId = await getRunnerGroupId(githubRunnerConfig, ghClient); + const { isDelay, delay } = addDelay(runnerIds); + const runnerLabels = githubRunnerConfig.runnerLabels.split(','); + const failedRunnerIds: string[] = []; + + logger.debug(`Runner group id: ${runnerGroupId}`); + logger.debug(`Runner labels: ${runnerLabels}`); + for (const runnerId of runnerIds) { + try { + // generate jit config for runner registration + const ephemeralRunnerConfig: EphemeralRunnerConfig = { + runnerName: `${githubRunnerConfig.runnerNamePrefix}${runnerId}`, + runnerGroupId: runnerGroupId, + runnerLabels: runnerLabels, + }; + logger.debug(`Runner name: ${ephemeralRunnerConfig.runnerName}`); + const runnerConfig = + githubRunnerConfig.runnerType === 'Org' + ? await ghClient.actions.generateRunnerJitconfigForOrg({ + org: githubRunnerConfig.runnerOwner, + name: ephemeralRunnerConfig.runnerName, + runner_group_id: ephemeralRunnerConfig.runnerGroupId, + labels: ephemeralRunnerConfig.runnerLabels, + }) + : await ghClient.actions.generateRunnerJitconfigForRepo({ + owner: githubRunnerConfig.runnerOwner.split('/')[0], + repo: githubRunnerConfig.runnerOwner.split('/')[1], + name: ephemeralRunnerConfig.runnerName, + runner_group_id: ephemeralRunnerConfig.runnerGroupId, + labels: ephemeralRunnerConfig.runnerLabels, + }); + + metricGitHubAppRateLimit(runnerConfig.headers); + + await options.onJitConfigCreated?.(runnerId, { + githubRunnerId: runnerConfig.data.runner.id.toString(), + runnerLabels, + }); + + // store jit config in ssm parameter store + logger.debug('Runner JIT config for ephemeral runner generated.', { + instance: runnerId, + }); + await putParameter(`${githubRunnerConfig.ssmTokenPath}/${runnerId}`, runnerConfig.data.encoded_jit_config, true, { + tags: [...(options.getSsmParameterTags?.(runnerId) ?? []), ...githubRunnerConfig.ssmParameterStoreTags], + }); + if (isDelay) { + // Delay to prevent AWS ssm rate limits by being within the max throughput limit + await delay(25); + } + } catch (error) { + failedRunnerIds.push(runnerId); + logger.warn('Failed to create JIT config for instance, continuing with remaining instances', { + instance: runnerId, + error: error instanceof Error ? error.message : String(error), + }); + } + } + + if (failedRunnerIds.length > 0) { + logger.error('Failed to create JIT config for some instances', { + failedInstances: failedRunnerIds, + totalInstances: runnerIds.length, + successfulInstances: runnerIds.length - failedRunnerIds.length, + }); + } + + return failedRunnerIds; +} + +export function getGitHubEnterpriseApiUrl() { + const ghesBaseUrl = process.env.GHES_URL; + let ghesApiUrl = ''; + if (ghesBaseUrl) { + const url = new URL(ghesBaseUrl); + const domain = url.hostname; + if (domain.endsWith('.ghe.com')) { + // Data residency: Prepend 'api.' + ghesApiUrl = `https://api.${domain}`; + } else { + // GitHub Enterprise Server: Append '/api/v3' + ghesApiUrl = `${ghesBaseUrl}/api/v3`; + } + } + logger.debug(`Github Enterprise URLs: api_url - ${ghesApiUrl}; base_url - ${ghesBaseUrl}`); + return { ghesApiUrl, ghesBaseUrl }; +} 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..c4ff1e5d76 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 @@ -1,6 +1,6 @@ import { publishMessage } from '../aws/sqs'; import { publishRetryMessage, checkAndRetryJob } from './job-retry'; -import { ActionRequestMessage, ActionRequestMessageRetry } from './scale-up'; +import type { ActionRequestMessage, ActionRequestMessageRetry } from './types'; import { getOctokit } from '../github/octokit'; import { jobRetryCheck } from '../lambda'; import { Octokit } from '@octokit/rest'; 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..8f7d6e2289 100644 --- a/lambdas/functions/control-plane/src/scale-runners/job-retry.ts +++ b/lambdas/functions/control-plane/src/scale-runners/job-retry.ts @@ -1,6 +1,7 @@ import { addPersistentContextToChildLogger, createSingleMetric, logger } from '@aws-github-runner/aws-powertools-util'; import { publishMessage } from '../aws/sqs'; -import { ActionRequestMessage, ActionRequestMessageRetry, isJobQueued, getGitHubEnterpriseApiUrl } from './scale-up'; +import { getGitHubEnterpriseApiUrl, isJobQueued } from './github-runner'; +import type { ActionRequestMessage, ActionRequestMessageRetry } from './types'; import { getOctokit } from '../github/octokit'; import { MetricUnit } from '@aws-lambda-powertools/metrics'; import yn from 'yn'; diff --git a/lambdas/functions/control-plane/src/scale-runners/scale-down-provider.ts b/lambdas/functions/control-plane/src/scale-runners/scale-down-provider.ts new file mode 100644 index 0000000000..c4966201aa --- /dev/null +++ b/lambdas/functions/control-plane/src/scale-runners/scale-down-provider.ts @@ -0,0 +1,26 @@ +import type { RunnerProvider } from '@aws-github-runner/runner-provider'; + +export interface RunnerList { + id: string; + launchTime?: Date; + owner?: string; + type?: string; + repo?: string; + org?: string; + orphan?: boolean; + githubRunnerId?: string; + bypassRemoval?: boolean; +} + +export interface RunnerInfo extends RunnerList { + owner: string; + type: string; +} + +export interface ScaleDownRunnerProvider extends RunnerProvider { + list(environment: string, orphan?: boolean): Promise; + bootTimeExceeded(runner: RunnerInfo): boolean; + markOrphan(id: string): Promise; + unmarkOrphan(id: string): Promise; + terminate(id: string): Promise; +} 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 730cf29bf7..beda1af4e7 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 @@ -3,9 +3,9 @@ import { RequestError } from '@octokit/request-error'; import moment from 'moment'; import nock from 'nock'; -import { RunnerInfo, RunnerList } from '../aws/runners.d'; +import { RunnerInfo, RunnerList } from '../aws/ec2-runners.d'; import * as ghAuth from '../github/auth'; -import { listEC2Runners, terminateRunner, tag, untag } from './../aws/runners'; +import { listEC2Runners, terminateRunner, tag, untag } from './../aws/ec2-runners'; import { githubCache } from './cache'; import { newestFirstStrategy, oldestFirstStrategy, scaleDown } from './scale-down'; import { describe, it, expect, beforeEach, vi } from 'vitest'; @@ -31,7 +31,7 @@ vi.mock('@octokit/rest', () => ({ }), })); -vi.mock('./../aws/runners', async (importOriginal) => { +vi.mock('./../aws/ec2-runners', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, 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 5fecc14c99..c517f89b9c 100644 --- a/lambdas/functions/control-plane/src/scale-runners/scale-down.ts +++ b/lambdas/functions/control-plane/src/scale-runners/scale-down.ts @@ -2,15 +2,16 @@ import { Octokit } from '@octokit/rest'; import { Endpoints } from '@octokit/types'; import { RequestError } from '@octokit/request-error'; import { createChildLogger } from '@aws-github-runner/aws-powertools-util'; +import { resolveRunnerProviderType } from '@aws-github-runner/runner-provider'; import moment from 'moment'; import { createGithubAppAuth, createGithubInstallationAuth, createOctokitClient } from '../github/auth'; -import { bootTimeExceeded, listEC2Runners, tag, untag, terminateRunner } from './../aws/runners'; -import { RunnerInfo, RunnerList } from './../aws/runners.d'; +import { createScaleDownRunnerProvider } from '../runner-provider-registry'; import { GhRunners, githubCache } from './cache'; -import { ScalingDownConfig, getEvictionStrategy, getIdleRunnerCount } from './scale-down-config'; +import { ScalingDownConfigList, getEvictionStrategy, getIdleRunnerCount } from './scale-down-config'; import { metricGitHubAppRateLimit } from '../github/rate-limit'; -import { getGitHubEnterpriseApiUrl } from './scale-up'; +import { getGitHubEnterpriseApiUrl } from './github-runner'; +import type { RunnerInfo, RunnerList, ScaleDownRunnerProvider } from './scale-down-provider'; const logger = createChildLogger('scale-down'); @@ -54,42 +55,40 @@ async function getOrCreateOctokit(runner: RunnerInfo): Promise { async function getGitHubSelfHostedRunnerState( client: Octokit, - ec2runner: RunnerInfo, + runner: RunnerInfo, runnerId: number, ): Promise { try { const state = - ec2runner.type === 'Org' + runner.type === 'Org' ? await client.actions.getSelfHostedRunnerForOrg({ runner_id: runnerId, - org: ec2runner.owner, + org: runner.owner, }) : await client.actions.getSelfHostedRunnerForRepo({ runner_id: runnerId, - owner: ec2runner.owner.split('/')[0], - repo: ec2runner.owner.split('/')[1], + owner: runner.owner.split('/')[0], + repo: runner.owner.split('/')[1], }); metricGitHubAppRateLimit(state.headers); return state.data; } catch (error) { if (error instanceof RequestError && error.status === 404) { - logger.info(`Runner '${ec2runner.instanceId}' with GitHub Runner ID '${runnerId}' not found on GitHub (404)`); + logger.info(`Runner '${runner.id}' with GitHub Runner ID '${runnerId}' not found on GitHub (404)`); return null; } throw error; } } -async function getGitHubRunnerBusyState(client: Octokit, ec2runner: RunnerInfo, runnerId: number): Promise { - const state = await getGitHubSelfHostedRunnerState(client, ec2runner, runnerId); +async function getGitHubRunnerBusyState(client: Octokit, runner: RunnerInfo, runnerId: number): Promise { + const state = await getGitHubSelfHostedRunnerState(client, runner, runnerId); if (state === null) { - logger.info( - `Runner '${ec2runner.instanceId}' - GitHub Runner ID '${runnerId}' - Not found on GitHub, treating as not busy`, - ); + logger.info(`Runner '${runner.id}' - GitHub Runner ID '${runnerId}' - Not found on GitHub, treating as not busy`); return false; } - logger.info(`Runner '${ec2runner.instanceId}' - GitHub Runner ID '${runnerId}' - Busy: ${state.busy}`); + logger.info(`Runner '${runner.id}' - GitHub Runner ID '${runnerId}' - Busy: ${state.busy}`); return state.busy; } @@ -132,18 +131,18 @@ function runnerMinimumTimeExceeded(runner: RunnerInfo): boolean { async function deleteGitHubRunner( githubInstallationClient: Octokit, - ec2runner: RunnerInfo, + runner: RunnerInfo, ghRunnerId: number, ): Promise<{ ghRunnerId: number; status: number; success: boolean }> { try { let response; - if (ec2runner.type === 'Org') { + if (runner.type === 'Org') { response = await githubInstallationClient.actions.deleteSelfHostedRunnerFromOrg({ runner_id: ghRunnerId, - org: ec2runner.owner, + org: runner.owner, }); } else { - const [owner, repo] = ec2runner.owner.split('/'); + const [owner, repo] = runner.owner.split('/'); response = await githubInstallationClient.actions.deleteSelfHostedRunnerFromRepo({ runner_id: ghRunnerId, owner, @@ -153,7 +152,7 @@ async function deleteGitHubRunner( return { ghRunnerId, status: response.status, success: response.status === 204 }; } catch (error) { logger.error( - `Failed to de-register GitHub runner ${ghRunnerId} for instance '${ec2runner.instanceId}'. ` + + `Failed to de-register GitHub runner ${ghRunnerId} for runner '${runner.id}'. ` + `Error: ${error instanceof Error ? error.message : String(error)}`, { error }, ); @@ -161,13 +160,16 @@ async function deleteGitHubRunner( } } -async function removeRunner(ec2runner: RunnerInfo, ghRunnerIds: number[]): Promise { - const githubInstallationClient = await getOrCreateOctokit(ec2runner); +async function removeRunner( + runner: RunnerInfo, + ghRunnerIds: number[], + runnerProvider: ScaleDownRunnerProvider, +): Promise { + const githubInstallationClient = await getOrCreateOctokit(runner); try { - const runnerList = ec2runner as unknown as RunnerList; - if (runnerList.bypassRemoval) { + if (runner.bypassRemoval) { logger.info( - `Runner '${ec2runner.instanceId}' has bypass-removal tag set, skipping removal. Remove the tag to allow scale-down.`, + `Runner '${runner.id}' has bypass-removal tag set, skipping removal. Remove the tag to allow scale-down.`, ); return; } @@ -175,153 +177,148 @@ async function removeRunner(ec2runner: RunnerInfo, ghRunnerIds: number[]): Promi const states = await Promise.all( ghRunnerIds.map(async (ghRunnerId) => { // Get busy state instead of using the output of listGitHubRunners(...) to minimize to race condition. - return await getGitHubRunnerBusyState(githubInstallationClient, ec2runner, ghRunnerId); + return await getGitHubRunnerBusyState(githubInstallationClient, runner, ghRunnerId); }), ); if (states.every((busy) => busy === false)) { const results = await Promise.all( - ghRunnerIds.map((ghRunnerId) => deleteGitHubRunner(githubInstallationClient, ec2runner, ghRunnerId)), + ghRunnerIds.map((ghRunnerId) => deleteGitHubRunner(githubInstallationClient, runner, ghRunnerId)), ); const allSucceeded = results.every((r) => r.success); const failedRunners = results.filter((r) => !r.success); if (allSucceeded) { - await terminateRunner(ec2runner.instanceId); - logger.info(`AWS runner instance '${ec2runner.instanceId}' is terminated and GitHub runner is de-registered.`); + await runnerProvider.terminate(runner.id); + logger.info( + `${runnerProvider.type.toUpperCase()} runner '${runner.id}' is terminated and GitHub runner is de-registered.`, + ); } else { - // Only terminate EC2 if we successfully de-registered from GitHub - // Otherwise, leave the instance running so the next scale-down cycle can retry + // Only terminate the provider runner if it was successfully de-registered from GitHub. logger.error( - `Failed to de-register ${failedRunners.length} GitHub runner(s) for instance '${ec2runner.instanceId}'. ` + - `Instance will NOT be terminated to allow retry on next scale-down cycle. ` + + `Failed to de-register ${failedRunners.length} GitHub runner(s) for runner '${runner.id}'. ` + + `Runner will NOT be terminated to allow retry on next scale-down cycle. ` + `Failed runner IDs: ${failedRunners.map((r) => r.ghRunnerId).join(', ')}`, ); } } else { - logger.info(`Runner '${ec2runner.instanceId}' cannot be de-registered, because it is still busy.`); + logger.info(`Runner '${runner.id}' cannot be de-registered, because it is still busy.`); } } catch (e) { logger.error( - `Runner '${ec2runner.instanceId}' cannot be de-registered. Error: ${e instanceof Error ? e.message : String(e)}`, + `Runner '${runner.id}' cannot be de-registered. Error: ${e instanceof Error ? e.message : String(e)}`, { error: e }, ); } } async function evaluateAndRemoveRunners( - ec2Runners: RunnerInfo[], - scaleDownConfigs: ScalingDownConfig[], + runners: RunnerInfo[], + scaleDownConfigs: ScalingDownConfigList, + runnerProvider: ScaleDownRunnerProvider, ): Promise { let idleCounter = getIdleRunnerCount(scaleDownConfigs); const evictionStrategy = getEvictionStrategy(scaleDownConfigs); - const ownerTags = new Set(ec2Runners.map((runner) => runner.owner)); + const ownerTags = new Set(runners.map((runner) => runner.owner)); for (const ownerTag of ownerTags) { - const ec2RunnersFiltered = ec2Runners + const ownerRunners = runners .filter((runner) => runner.owner === ownerTag) .sort(evictionStrategy === 'oldest_first' ? oldestFirstStrategy : newestFirstStrategy); - logger.debug(`Found: '${ec2RunnersFiltered.length}' active GitHub runners with owner tag: '${ownerTag}'`); - logger.debug(`Active GitHub runners with owner tag: '${ownerTag}': ${JSON.stringify(ec2RunnersFiltered)}`); - for (const ec2Runner of ec2RunnersFiltered) { - if ((ec2Runner as unknown as RunnerList).bypassRemoval) { - logger.debug(`Runner '${ec2Runner.instanceId}' has bypass-removal tag set, skipping evaluation.`); + logger.debug(`Found: '${ownerRunners.length}' active GitHub runners with owner tag: '${ownerTag}'`); + logger.debug(`Active GitHub runners with owner tag: '${ownerTag}': ${JSON.stringify(ownerRunners)}`); + for (const runner of ownerRunners) { + if (runner.bypassRemoval) { + logger.debug(`Runner '${runner.id}' has bypass-removal tag set, skipping evaluation.`); continue; } - const ghRunners = await listGitHubRunners(ec2Runner); - const ghRunnersFiltered = ghRunners.filter((runner: { name: string }) => - runner.name.endsWith(ec2Runner.instanceId), - ); - logger.debug( - `Found: '${ghRunnersFiltered.length}' GitHub runners for AWS runner instance: '${ec2Runner.instanceId}'`, - ); - logger.debug( - `GitHub runners for AWS runner instance: '${ec2Runner.instanceId}': ${JSON.stringify(ghRunnersFiltered)}`, - ); + const ghRunners = await listGitHubRunners(runner); + const ghRunnersFiltered = ghRunners.filter((ghRunner: { name: string }) => ghRunner.name.endsWith(runner.id)); + logger.debug(`Found: '${ghRunnersFiltered.length}' GitHub runners for runner: '${runner.id}'`); + logger.debug(`GitHub runners for runner: '${runner.id}': ${JSON.stringify(ghRunnersFiltered)}`); if (ghRunnersFiltered.length) { - if (runnerMinimumTimeExceeded(ec2Runner)) { + if (runnerMinimumTimeExceeded(runner)) { if (idleCounter > 0) { idleCounter--; - logger.info(`Runner '${ec2Runner.instanceId}' will be kept idle.`); + logger.info(`Runner '${runner.id}' will be kept idle.`); } else { logger.info(`Terminating all non busy runners.`); await removeRunner( - ec2Runner, + runner, ghRunnersFiltered.map((runner: { id: number }) => runner.id), + runnerProvider, ); } } - } else if (bootTimeExceeded(ec2Runner)) { - await markOrphan(ec2Runner.instanceId); + } else if (runnerProvider.bootTimeExceeded(runner)) { + await markOrphan(runner.id, runnerProvider); } else { - logger.debug(`Runner ${ec2Runner.instanceId} has not yet booted.`); + logger.debug(`Runner ${runner.id} has not yet booted.`); } } } } -async function markOrphan(instanceId: string): Promise { +async function markOrphan(id: string, runnerProvider: ScaleDownRunnerProvider): Promise { try { - await tag(instanceId, [{ Key: 'ghr:orphan', Value: 'true' }]); - logger.info(`Runner '${instanceId}' tagged as orphan.`); + await runnerProvider.markOrphan(id); + logger.info(`Runner '${id}' tagged as orphan.`); } catch (e) { - logger.error(`Failed to tag runner '${instanceId}' as orphan.`, { error: e }); + logger.error(`Failed to tag runner '${id}' as orphan.`, { error: e }); } } -async function unMarkOrphan(instanceId: string): Promise { +async function unMarkOrphan(id: string, runnerProvider: ScaleDownRunnerProvider): Promise { try { - await untag(instanceId, [{ Key: 'ghr:orphan', Value: 'true' }]); - logger.info(`Runner '${instanceId}' untagged as orphan.`); + await runnerProvider.unmarkOrphan(id); + logger.info(`Runner '${id}' untagged as orphan.`); } catch (e) { - logger.error(`Failed to un-tag runner '${instanceId}' as orphan.`, { error: e }); + logger.error(`Failed to un-tag runner '${id}' as orphan.`, { error: e }); } } async function lastChanceCheckOrphanRunner(runner: RunnerList): Promise { - const client = await getOrCreateOctokit(runner as RunnerInfo); - const runnerId = parseInt(runner.runnerId || '0'); - const ec2Instance = runner as RunnerInfo; - const state = await getGitHubSelfHostedRunnerState(client, ec2Instance, runnerId); + const registeredRunner = runner as RunnerInfo; + const client = await getOrCreateOctokit(registeredRunner); + const runnerId = parseInt(runner.githubRunnerId || '0'); + const state = await getGitHubSelfHostedRunnerState(client, registeredRunner, runnerId); let isOrphan = false; if (state === null) { - logger.debug(`Runner '${runner.instanceId}' not found on GitHub, treating as orphaned.`); + logger.debug(`Runner '${runner.id}' not found on GitHub, treating as orphaned.`); isOrphan = true; } else { - logger.debug( - `Runner '${runner.instanceId}' is '${state.status}' and is currently '${state.busy ? 'busy' : 'idle'}'.`, - ); + logger.debug(`Runner '${runner.id}' is '${state.status}' and is currently '${state.busy ? 'busy' : 'idle'}'.`); const isOfflineAndBusy = state.status === 'offline' && state.busy; if (isOfflineAndBusy) { isOrphan = true; } } - logger.info(`Runner '${runner.instanceId}' is judged to ${isOrphan ? 'be' : 'not be'} orphaned.`); + logger.info(`Runner '${runner.id}' is judged to ${isOrphan ? 'be' : 'not be'} orphaned.`); return isOrphan; } -async function terminateOrphan(environment: string): Promise { +async function terminateOrphan(environment: string, runnerProvider: ScaleDownRunnerProvider): Promise { try { - const orphanRunners = await listEC2Runners({ environment, orphan: true }); + const orphanRunners = await runnerProvider.list(environment, true); for (const runner of orphanRunners) { if (runner.bypassRemoval) { - logger.info(`Orphan runner '${runner.instanceId}' has bypass-removal tag set, skipping termination.`); + logger.info(`Orphan runner '${runner.id}' has bypass-removal tag set, skipping termination.`); continue; } - if (runner.runnerId) { + if (runner.githubRunnerId) { const isOrphan = await lastChanceCheckOrphanRunner(runner); if (isOrphan) { - await terminateRunner(runner.instanceId); + await runnerProvider.terminate(runner.id); } else { - await unMarkOrphan(runner.instanceId); + await unMarkOrphan(runner.id, runnerProvider); } } else { - logger.info(`Terminating orphan runner '${runner.instanceId}'`); - await terminateRunner(runner.instanceId).catch((e) => { - logger.error(`Failed to terminate orphan runner '${runner.instanceId}'`, { error: e }); + logger.info(`Terminating orphan runner '${runner.id}'`); + await runnerProvider.terminate(runner.id).catch((e) => { + logger.error(`Failed to terminate orphan runner '${runner.id}'`, { error: e }); }); } } @@ -342,38 +339,42 @@ export function newestFirstStrategy(a: RunnerInfo, b: RunnerInfo): number { return oldestFirstStrategy(a, b) * -1; } -async function listRunners(environment: string) { - return await listEC2Runners({ - environment, - }); +async function listRunners(environment: string, runnerProvider: ScaleDownRunnerProvider) { + return await runnerProvider.list(environment); } -function filterRunners(ec2runners: RunnerList[]): RunnerInfo[] { - return ec2runners.filter((ec2Runner) => ec2Runner.type && !ec2Runner.orphan) as RunnerInfo[]; +function filterRunners(runners: RunnerList[]): RunnerInfo[] { + return runners.filter((runner) => runner.owner && runner.type && !runner.orphan) as RunnerInfo[]; } export async function scaleDown(): Promise { githubCache.reset(); const environment = process.env.ENVIRONMENT; - const scaleDownConfigs = JSON.parse(process.env.SCALE_DOWN_CONFIG) as [ScalingDownConfig]; + const scaleDownConfigs = JSON.parse(process.env.SCALE_DOWN_CONFIG) as ScalingDownConfigList; + const runnerProviderType = resolveRunnerProviderType(process.env.RUNNER_PROVIDER_TYPE); + const runnerProvider = createScaleDownRunnerProvider(runnerProviderType); // first runners marked to be orphan. - await terminateOrphan(environment); + await terminateOrphan(environment, runnerProvider); // next scale down idle runners with respect to config and mark potential orphans - const ec2Runners = await listRunners(environment); - const activeEc2RunnersCount = ec2Runners.length; - logger.info(`Found: '${activeEc2RunnersCount}' active GitHub EC2 runner instances before clean-up.`); - logger.debug(`Active GitHub EC2 runner instances: ${JSON.stringify(ec2Runners)}`); - - if (activeEc2RunnersCount === 0) { + const providerRunners = await listRunners(environment, runnerProvider); + const activeProviderRunnersCount = providerRunners.length; + logger.info( + `Found: '${activeProviderRunnersCount}' active ${runnerProvider.type.toUpperCase()} runners before clean-up.`, + ); + logger.debug(`Active ${runnerProvider.type.toUpperCase()} runners: ${JSON.stringify(providerRunners)}`); + + if (activeProviderRunnersCount === 0) { logger.debug(`No active runners found for environment: '${environment}'`); return; } - const runners = filterRunners(ec2Runners); - await evaluateAndRemoveRunners(runners, scaleDownConfigs); + const runners = filterRunners(providerRunners); + await evaluateAndRemoveRunners(runners, scaleDownConfigs, runnerProvider); - const activeEc2RunnersCountAfter = (await listRunners(environment)).length; - logger.info(`Found: '${activeEc2RunnersCountAfter}' active GitHub EC2 runners instances after clean-up.`); + const activeProviderRunnersCountAfter = (await listRunners(environment, runnerProvider)).length; + logger.info( + `Found: '${activeProviderRunnersCountAfter}' active ${runnerProvider.type.toUpperCase()} runners after clean-up.`, + ); } diff --git a/lambdas/functions/control-plane/src/scale-runners/scale-up-provider.ts b/lambdas/functions/control-plane/src/scale-runners/scale-up-provider.ts new file mode 100644 index 0000000000..fc34d7f243 --- /dev/null +++ b/lambdas/functions/control-plane/src/scale-runners/scale-up-provider.ts @@ -0,0 +1,28 @@ +import type { Octokit } from '@octokit/rest'; +import type { RunnerProvider } from '@aws-github-runner/runner-provider'; + +import type { ActionRequestMessageSQS, CreateGitHubRunnerConfig, GitHubRunnerType } from './types'; + +export interface CurrentRunnersInput { + runnerType: GitHubRunnerType; + runnerOwner: string; +} + +export interface CreateScaleUpRunnersInput { + githubRunnerConfig: CreateGitHubRunnerConfig; + numberOfRunners: number; + githubInstallationClient: Octokit; + messages: ActionRequestMessageSQS[]; + state: TState; +} + +export interface PreparedScaleUpRunnerGroup { + runnerLabels: string[]; + state: TState; +} + +export interface ScaleUpRunnerProvider extends RunnerProvider { + prepareGroup(messageLabels: string[]): Promise>; + getCurrentRunners(state: TState, input: CurrentRunnersInput): Promise; + createRunners(input: CreateScaleUpRunnersInput): Promise; +} 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 f0a2706c1d..bf27b92c6a 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 @@ -7,13 +7,16 @@ import nock from 'nock'; import { performance } from 'perf_hooks'; import * as ghAuth from '../github/auth'; -import { createRunner, listEC2Runners, tag } from './../aws/runners'; -import { RunnerInputParameters } from './../aws/runners.d'; +import { createRunner, listEC2Runners, tag } from './../aws/ec2-runners'; +import { RunnerInputParameters } from './../aws/ec2-runners.d'; import * as scaleUpModule from './scale-up'; +import { parseEc2OverrideConfig } from './ec2-labels'; +import { EC2_TAG_VALUE_MAX_LENGTH, RUNNER_LABELS_TAG_MAX_COUNT } from './ec2'; import { getParameter } from '@aws-github-runner/aws-ssm-util'; import { publishRetryMessage } from './job-retry'; import { describe, it, expect, beforeEach, vi } from 'vitest'; import type { Octokit } from '@octokit/rest'; +import type { ActionRequestMessageSQS } from './types'; const mockOctokit = { paginate: vi.fn(), @@ -45,7 +48,7 @@ vi.mock('@octokit/rest', () => ({ }), })); -vi.mock('./../aws/runners', async () => ({ +vi.mock('./../aws/ec2-runners', async () => ({ createRunner: vi.fn(), listEC2Runners: vi.fn(), tag: vi.fn(), @@ -82,7 +85,7 @@ const mockedAppAuth = vi.mocked(ghAuth.createGithubAppAuth); const mockedInstallationAuth = vi.mocked(ghAuth.createGithubInstallationAuth); const mockCreateClient = vi.mocked(ghAuth.createOctokitClient); -const TEST_DATA_SINGLE: scaleUpModule.ActionRequestMessageSQS = { +const TEST_DATA_SINGLE: ActionRequestMessageSQS = { id: 1, eventType: 'workflow_job', repositoryName: 'hello-world', @@ -92,7 +95,7 @@ const TEST_DATA_SINGLE: scaleUpModule.ActionRequestMessageSQS = { messageId: 'foobar', }; -const TEST_DATA: scaleUpModule.ActionRequestMessageSQS[] = [ +const TEST_DATA: ActionRequestMessageSQS[] = [ { ...TEST_DATA_SINGLE, messageId: 'foobar', @@ -298,24 +301,24 @@ describe('scaleUp with GHES', () => { }); it('chunks comma-joined GitHub runner labels by the EC2 tag value max length', async () => { - const runnerLabels = ['a'.repeat(scaleUpModule.EC2_TAG_VALUE_MAX_LENGTH), 'b'].join(','); + const runnerLabels = ['a'.repeat(EC2_TAG_VALUE_MAX_LENGTH), 'b'].join(','); process.env.RUNNER_LABELS = runnerLabels; await scaleUpModule.scaleUp(TEST_DATA); expect(mockTag).toHaveBeenCalledWith('i-12345', [ { Key: 'ghr:github_runner_id', Value: '9876543210' }, - { Key: 'ghr:runner_labels', Value: runnerLabels.slice(0, scaleUpModule.EC2_TAG_VALUE_MAX_LENGTH) }, + { Key: 'ghr:runner_labels', Value: runnerLabels.slice(0, EC2_TAG_VALUE_MAX_LENGTH) }, { Key: 'ghr:runner_labels:2', - Value: runnerLabels.slice(scaleUpModule.EC2_TAG_VALUE_MAX_LENGTH), + Value: runnerLabels.slice(EC2_TAG_VALUE_MAX_LENGTH), }, ]); }); it('limits GitHub runner label metadata to five EC2 tags', async () => { - const runnerLabels = Array.from({ length: scaleUpModule.RUNNER_LABELS_TAG_MAX_COUNT + 1 }, (_, index) => - String.fromCharCode('a'.charCodeAt(0) + index).repeat(scaleUpModule.EC2_TAG_VALUE_MAX_LENGTH), + const runnerLabels = Array.from({ length: RUNNER_LABELS_TAG_MAX_COUNT + 1 }, (_, index) => + String.fromCharCode('a'.charCodeAt(0) + index).repeat(EC2_TAG_VALUE_MAX_LENGTH), ).join(','); process.env.RUNNER_LABELS = runnerLabels; @@ -323,12 +326,9 @@ describe('scaleUp with GHES', () => { expect(mockTag).toHaveBeenCalledWith('i-12345', [ { Key: 'ghr:github_runner_id', Value: '9876543210' }, - ...Array.from({ length: scaleUpModule.RUNNER_LABELS_TAG_MAX_COUNT }, (_, index) => ({ + ...Array.from({ length: RUNNER_LABELS_TAG_MAX_COUNT }, (_, index) => ({ Key: index === 0 ? 'ghr:runner_labels' : `ghr:runner_labels:${index + 1}`, - Value: runnerLabels.slice( - index * scaleUpModule.EC2_TAG_VALUE_MAX_LENGTH, - (index + 1) * scaleUpModule.EC2_TAG_VALUE_MAX_LENGTH, - ), + Value: runnerLabels.slice(index * EC2_TAG_VALUE_MAX_LENGTH, (index + 1) * EC2_TAG_VALUE_MAX_LENGTH), })), ]); }); @@ -1305,8 +1305,8 @@ describe('scaleUp with GHES', () => { const createTestMessages = ( count: number, - overrides: Partial[] = [], - ): scaleUpModule.ActionRequestMessageSQS[] => { + overrides: Partial[] = [], + ): ActionRequestMessageSQS[] => { return Array.from({ length: count }, (_, i) => ({ ...TEST_DATA_SINGLE, id: i + 1, @@ -1789,8 +1789,8 @@ describe('scaleUp with public GH', () => { describe('Batch processing', () => { const createTestMessages = ( count: number, - overrides: Partial[] = [], - ): scaleUpModule.ActionRequestMessageSQS[] => { + overrides: Partial[] = [], + ): ActionRequestMessageSQS[] => { return Array.from({ length: count }, (_, i) => ({ ...TEST_DATA_SINGLE, id: i + 1, @@ -2314,8 +2314,8 @@ describe('scaleUp with Github Data Residency', () => { describe('Batch processing', () => { const createTestMessages = ( count: number, - overrides: Partial[] = [], - ): scaleUpModule.ActionRequestMessageSQS[] => { + overrides: Partial[] = [], + ): ActionRequestMessageSQS[] => { return Array.from({ length: count }, (_, i) => ({ ...TEST_DATA_SINGLE, id: i + 1, @@ -2552,8 +2552,8 @@ describe('Retry mechanism tests', () => { const createTestMessages = ( count: number, - overrides: Partial[] = [], - ): scaleUpModule.ActionRequestMessageSQS[] => { + overrides: Partial[] = [], + ): ActionRequestMessageSQS[] => { return Array.from({ length: count }, (_, i) => ({ ...TEST_DATA_SINGLE, id: i + 1, @@ -2705,50 +2705,144 @@ describe('Retry mechanism tests', () => { }); }); +describe('useDedicatedHost', () => { + beforeEach(() => { + process.env.ENABLE_ORGANIZATION_RUNNERS = 'true'; + process.env.ENABLE_EPHEMERAL_RUNNERS = 'true'; + process.env.RUNNER_NAME_PREFIX = 'unit-test-'; + process.env.RUNNER_GROUP_NAME = 'Default'; + process.env.SSM_CONFIG_PATH = '/github-action-runners/default/runners/config'; + process.env.SSM_TOKEN_PATH = '/github-action-runners/default/runners/config'; + process.env.RUNNER_LABELS = 'label1,label2'; + }); + + it('defaults to false when USE_DEDICATED_HOST env var is not set', async () => { + delete process.env.USE_DEDICATED_HOST; + await scaleUpModule.scaleUp(TEST_DATA); + expect(createRunner).toHaveBeenCalledWith(expect.objectContaining({ useDedicatedHost: false })); + }); + + it('is true when USE_DEDICATED_HOST is "true"', async () => { + process.env.USE_DEDICATED_HOST = 'true'; + await scaleUpModule.scaleUp(TEST_DATA); + expect(createRunner).toHaveBeenCalledWith(expect.objectContaining({ useDedicatedHost: true })); + }); + + it('is false when USE_DEDICATED_HOST is "false"', async () => { + process.env.USE_DEDICATED_HOST = 'false'; + await scaleUpModule.scaleUp(TEST_DATA); + expect(createRunner).toHaveBeenCalledWith(expect.objectContaining({ useDedicatedHost: false })); + }); +}); + +function defaultOctokitMockImpl() { + mockOctokit.actions.getJobForWorkflowRun.mockImplementation(() => ({ + data: { + status: 'queued', + }, + })); + mockOctokit.paginate.mockImplementation(() => [ + { + id: 1, + name: 'Default', + }, + ]); + mockOctokit.actions.generateRunnerJitconfigForOrg.mockImplementation(({ labels }: { labels: string[] }) => ({ + data: { + runner: { id: 9876543210, labels: labels.map((name: string) => ({ name })) }, + encoded_jit_config: 'TEST_JIT_CONFIG_ORG', + }, + })); + mockOctokit.actions.generateRunnerJitconfigForRepo.mockImplementation(({ labels }: { labels: string[] }) => ({ + data: { + runner: { id: 9876543210, labels: labels.map((name: string) => ({ name })) }, + encoded_jit_config: 'TEST_JIT_CONFIG_REPO', + }, + })); + mockOctokit.checks.get.mockImplementation(() => ({ + data: { + status: 'queued', + }, + })); + + const mockTokenReturnValue = { + data: { + token: '1234abcd', + }, + }; + const mockInstallationIdReturnValueOrgs = { + data: { + id: TEST_DATA_SINGLE.installationId, + }, + }; + const mockInstallationIdReturnValueRepos = { + data: { + id: TEST_DATA_SINGLE.installationId, + }, + }; + + mockOctokit.actions.createRegistrationTokenForOrg.mockImplementation(() => mockTokenReturnValue); + mockOctokit.actions.createRegistrationTokenForRepo.mockImplementation(() => mockTokenReturnValue); + mockOctokit.apps.getOrgInstallation.mockImplementation(() => mockInstallationIdReturnValueOrgs); + mockOctokit.apps.getRepoInstallation.mockImplementation(() => mockInstallationIdReturnValueRepos); +} + +function defaultSSMGetParameterMockImpl() { + mockSSMgetParameter.mockImplementation(async (name: string) => { + if (name === `${process.env.SSM_CONFIG_PATH}/runner-group/${process.env.RUNNER_GROUP_NAME}`) { + return '1'; + } else if (name === `${process.env.PARAMETER_GITHUB_APP_ID_NAME}`) { + return `${process.env.GITHUB_APP_ID}`; + } else { + throw new Error(`ParameterNotFound: ${name}`); + } + }); +} + describe('parseEc2OverrideConfig', () => { describe('Basic Fleet Overrides', () => { it('should parse instance-type label', () => { - const result = scaleUpModule.parseEc2OverrideConfig(['ghr-ec2-instance-type:c5.xlarge']); + const result = parseEc2OverrideConfig(['ghr-ec2-instance-type:c5.xlarge']); expect(result?.InstanceType).toBe('c5.xlarge'); }); it('should parse subnet-id label', () => { - const result = scaleUpModule.parseEc2OverrideConfig(['ghr-ec2-subnet-id:subnet-123456']); + const result = parseEc2OverrideConfig(['ghr-ec2-subnet-id:subnet-123456']); expect(result?.SubnetId).toBe('subnet-123456'); }); it('should parse availability-zone label', () => { - const result = scaleUpModule.parseEc2OverrideConfig(['ghr-ec2-availability-zone:us-east-1a']); + const result = parseEc2OverrideConfig(['ghr-ec2-availability-zone:us-east-1a']); expect(result?.AvailabilityZone).toBe('us-east-1a'); }); it('should parse availability-zone-id label', () => { - const result = scaleUpModule.parseEc2OverrideConfig(['ghr-ec2-availability-zone-id:use1-az1']); + const result = parseEc2OverrideConfig(['ghr-ec2-availability-zone-id:use1-az1']); expect(result?.AvailabilityZoneId).toBe('use1-az1'); }); it('should parse max-price label', () => { - const result = scaleUpModule.parseEc2OverrideConfig(['ghr-ec2-max-price:0.50']); + const result = parseEc2OverrideConfig(['ghr-ec2-max-price:0.50']); expect(result?.MaxPrice).toBe('0.50'); }); it('should parse priority label as number', () => { - const result = scaleUpModule.parseEc2OverrideConfig(['ghr-ec2-priority:1']); + const result = parseEc2OverrideConfig(['ghr-ec2-priority:1']); expect(result?.Priority).toBe(1); }); it('should parse weighted-capacity label as number', () => { - const result = scaleUpModule.parseEc2OverrideConfig(['ghr-ec2-weighted-capacity:2']); + const result = parseEc2OverrideConfig(['ghr-ec2-weighted-capacity:2']); expect(result?.WeightedCapacity).toBe(2); }); it('should parse image-id label', () => { - const result = scaleUpModule.parseEc2OverrideConfig(['ghr-ec2-image-id:ami-12345678']); + const result = parseEc2OverrideConfig(['ghr-ec2-image-id:ami-12345678']); expect(result?.ImageId).toBe('ami-12345678'); }); it('should parse multiple basic fleet overrides', () => { - const result = scaleUpModule.parseEc2OverrideConfig([ + const result = parseEc2OverrideConfig([ 'ghr-ec2-instance-type:r5.2xlarge', 'ghr-ec2-max-price:1.00', 'ghr-ec2-priority:2', @@ -2761,52 +2855,52 @@ describe('parseEc2OverrideConfig', () => { describe('Placement', () => { it('should parse placement-group-name label', () => { - const result = scaleUpModule.parseEc2OverrideConfig(['ghr-ec2-placement-group-name:my-placement-group']); + const result = parseEc2OverrideConfig(['ghr-ec2-placement-group-name:my-placement-group']); expect(result?.Placement?.GroupName).toBe('my-placement-group'); }); it('should parse placement-group-id label', () => { - const result = scaleUpModule.parseEc2OverrideConfig(['ghr-ec2-placement-group-id:pg-1234567890abcdef0']); + const result = parseEc2OverrideConfig(['ghr-ec2-placement-group-id:pg-1234567890abcdef0']); expect(result?.Placement?.GroupId).toBe('pg-1234567890abcdef0'); }); it('should parse placement-tenancy label', () => { - const result = scaleUpModule.parseEc2OverrideConfig(['ghr-ec2-placement-tenancy:dedicated']); + const result = parseEc2OverrideConfig(['ghr-ec2-placement-tenancy:dedicated']); expect(result?.Placement?.Tenancy).toBe('dedicated'); }); it('should parse placement-host-id label', () => { - const result = scaleUpModule.parseEc2OverrideConfig(['ghr-ec2-placement-host-id:h-1234567890abcdef']); + const result = parseEc2OverrideConfig(['ghr-ec2-placement-host-id:h-1234567890abcdef']); expect(result?.Placement?.HostId).toBe('h-1234567890abcdef'); }); it('should parse placement-affinity label', () => { - const result = scaleUpModule.parseEc2OverrideConfig(['ghr-ec2-placement-affinity:host']); + const result = parseEc2OverrideConfig(['ghr-ec2-placement-affinity:host']); expect(result?.Placement?.Affinity).toBe('host'); }); it('should parse placement-partition-number label as number', () => { - const result = scaleUpModule.parseEc2OverrideConfig(['ghr-ec2-placement-partition-number:3']); + const result = parseEc2OverrideConfig(['ghr-ec2-placement-partition-number:3']); expect(result?.Placement?.PartitionNumber).toBe(3); }); it('should parse placement-availability-zone label', () => { - const result = scaleUpModule.parseEc2OverrideConfig(['ghr-ec2-placement-availability-zone:us-west-2b']); + const result = parseEc2OverrideConfig(['ghr-ec2-placement-availability-zone:us-west-2b']); expect(result?.Placement?.AvailabilityZone).toBe('us-west-2b'); }); it('should parse placement-availability-zone-id label', () => { - const result = scaleUpModule.parseEc2OverrideConfig(['ghr-ec2-placement-availability-zone-id:use1-az1']); + const result = parseEc2OverrideConfig(['ghr-ec2-placement-availability-zone-id:use1-az1']); expect(result?.Placement?.AvailabilityZoneId).toBe('use1-az1'); }); it('should parse placement-spread-domain label', () => { - const result = scaleUpModule.parseEc2OverrideConfig(['ghr-ec2-placement-spread-domain:my-spread-domain']); + const result = parseEc2OverrideConfig(['ghr-ec2-placement-spread-domain:my-spread-domain']); expect(result?.Placement?.SpreadDomain).toBe('my-spread-domain'); }); it('should parse placement-host-resource-group-arn label', () => { - const result = scaleUpModule.parseEc2OverrideConfig([ + const result = parseEc2OverrideConfig([ 'ghr-ec2-placement-host-resource-group-arn:arn:aws:ec2:us-east-1:123456789012:host-resource-group/hrg-1234', ]); expect(result?.Placement?.HostResourceGroupArn).toBe( @@ -2815,7 +2909,7 @@ describe('parseEc2OverrideConfig', () => { }); it('should parse multiple placement labels', () => { - const result = scaleUpModule.parseEc2OverrideConfig([ + const result = parseEc2OverrideConfig([ 'ghr-ec2-placement-group-name:group-1', 'ghr-ec2-placement-tenancy:dedicated', 'ghr-ec2-placement-availability-zone:us-east-1b', @@ -2828,48 +2922,48 @@ describe('parseEc2OverrideConfig', () => { describe('Block Device Mappings', () => { it('should parse block-device-name label', () => { - const result = scaleUpModule.parseEc2OverrideConfig(['ghr-ec2-block-device-name:/dev/sdg']); + const result = parseEc2OverrideConfig(['ghr-ec2-block-device-name:/dev/sdg']); expect(result?.BlockDeviceMappings?.[0]?.DeviceName).toBe('/dev/sdg'); }); it('should use default block device name when provided', () => { - const result = scaleUpModule.parseEc2OverrideConfig(['ghr-ec2-ebs-volume-size:100'], '/dev/sda1'); + const result = parseEc2OverrideConfig(['ghr-ec2-ebs-volume-size:100'], '/dev/sda1'); expect(result?.BlockDeviceMappings?.[0]?.DeviceName).toBe('/dev/sda1'); expect(result?.BlockDeviceMappings?.[0]?.Ebs?.VolumeSize).toBe(100); }); it('should parse ebs-volume-size label as number', () => { - const result = scaleUpModule.parseEc2OverrideConfig(['ghr-ec2-ebs-volume-size:100']); + const result = parseEc2OverrideConfig(['ghr-ec2-ebs-volume-size:100']); expect(result?.BlockDeviceMappings?.[0]?.Ebs?.VolumeSize).toBe(100); }); it('should parse ebs-volume-type label', () => { - const result = scaleUpModule.parseEc2OverrideConfig(['ghr-ec2-ebs-volume-type:gp3']); + const result = parseEc2OverrideConfig(['ghr-ec2-ebs-volume-type:gp3']); expect(result?.BlockDeviceMappings?.[0]?.Ebs?.VolumeType).toBe('gp3'); }); it('should parse ebs-iops label as number', () => { - const result = scaleUpModule.parseEc2OverrideConfig(['ghr-ec2-ebs-iops:3000']); + const result = parseEc2OverrideConfig(['ghr-ec2-ebs-iops:3000']); expect(result?.BlockDeviceMappings?.[0]?.Ebs?.Iops).toBe(3000); }); it('should parse ebs-throughput label as number', () => { - const result = scaleUpModule.parseEc2OverrideConfig(['ghr-ec2-ebs-throughput:250']); + const result = parseEc2OverrideConfig(['ghr-ec2-ebs-throughput:250']); expect(result?.BlockDeviceMappings?.[0]?.Ebs?.Throughput).toBe(250); }); it('should parse ebs-encrypted label as boolean true', () => { - const result = scaleUpModule.parseEc2OverrideConfig(['ghr-ec2-ebs-encrypted:true']); + const result = parseEc2OverrideConfig(['ghr-ec2-ebs-encrypted:true']); expect(result?.BlockDeviceMappings?.[0]?.Ebs?.Encrypted).toBe(true); }); it('should parse ebs-encrypted label as boolean false', () => { - const result = scaleUpModule.parseEc2OverrideConfig(['ghr-ec2-ebs-encrypted:false']); + const result = parseEc2OverrideConfig(['ghr-ec2-ebs-encrypted:false']); expect(result?.BlockDeviceMappings?.[0]?.Ebs?.Encrypted).toBe(false); }); it('should parse ebs-kms-key-id label', () => { - const result = scaleUpModule.parseEc2OverrideConfig([ + const result = parseEc2OverrideConfig([ 'ghr-ec2-ebs-kms-key-id:arn:aws:kms:us-east-1:123456789012:key/12345678-1234-1234-1234-123456789012', ]); expect(result?.BlockDeviceMappings?.[0]?.Ebs?.KmsKeyId).toBe( @@ -2878,32 +2972,32 @@ describe('parseEc2OverrideConfig', () => { }); it('should parse ebs-delete-on-termination label as boolean true', () => { - const result = scaleUpModule.parseEc2OverrideConfig(['ghr-ec2-ebs-delete-on-termination:true']); + const result = parseEc2OverrideConfig(['ghr-ec2-ebs-delete-on-termination:true']); expect(result?.BlockDeviceMappings?.[0]?.Ebs?.DeleteOnTermination).toBe(true); }); it('should parse ebs-delete-on-termination label as boolean false', () => { - const result = scaleUpModule.parseEc2OverrideConfig(['ghr-ec2-ebs-delete-on-termination:false']); + const result = parseEc2OverrideConfig(['ghr-ec2-ebs-delete-on-termination:false']); expect(result?.BlockDeviceMappings?.[0]?.Ebs?.DeleteOnTermination).toBe(false); }); it('should parse ebs-snapshot-id label', () => { - const result = scaleUpModule.parseEc2OverrideConfig(['ghr-ec2-ebs-snapshot-id:snap-1234567890abcdef']); + const result = parseEc2OverrideConfig(['ghr-ec2-ebs-snapshot-id:snap-1234567890abcdef']); expect(result?.BlockDeviceMappings?.[0]?.Ebs?.SnapshotId).toBe('snap-1234567890abcdef'); }); it('should parse block-device-virtual-name label', () => { - const result = scaleUpModule.parseEc2OverrideConfig(['ghr-ec2-block-device-virtual-name:ephemeral0']); + const result = parseEc2OverrideConfig(['ghr-ec2-block-device-virtual-name:ephemeral0']); expect(result?.BlockDeviceMappings?.[0]?.VirtualName).toBe('ephemeral0'); }); it('should parse block-device-no-device label', () => { - const result = scaleUpModule.parseEc2OverrideConfig(['ghr-ec2-block-device-no-device:true']); + const result = parseEc2OverrideConfig(['ghr-ec2-block-device-no-device:true']); expect(result?.BlockDeviceMappings?.[0]?.NoDevice).toBe('true'); }); it('should parse multiple block device mapping labels', () => { - const result = scaleUpModule.parseEc2OverrideConfig([ + const result = parseEc2OverrideConfig([ 'ghr-ec2-ebs-volume-size:200', 'ghr-ec2-ebs-volume-type:gp3', 'ghr-ec2-ebs-iops:5000', @@ -2916,59 +3010,56 @@ describe('parseEc2OverrideConfig', () => { }); it('should initialize BlockDeviceMappings when not present', () => { - const result = scaleUpModule.parseEc2OverrideConfig(['ghr-ec2-ebs-volume-size:50']); + const result = parseEc2OverrideConfig(['ghr-ec2-ebs-volume-size:50']); expect(result?.BlockDeviceMappings).toBeDefined(); }); }); describe('Instance Requirements - vCPU and Memory', () => { it('should parse vcpu-count-min label', () => { - const result = scaleUpModule.parseEc2OverrideConfig(['ghr-ec2-vcpu-count-min:4']); + const result = parseEc2OverrideConfig(['ghr-ec2-vcpu-count-min:4']); expect(result?.InstanceRequirements?.VCpuCount?.Min).toBe(4); }); it('should parse vcpu-count-max label', () => { - const result = scaleUpModule.parseEc2OverrideConfig(['ghr-ec2-vcpu-count-max:16']); + const result = parseEc2OverrideConfig(['ghr-ec2-vcpu-count-max:16']); expect(result?.InstanceRequirements?.VCpuCount?.Max).toBe(16); }); it('should parse both vcpu-count-min and vcpu-count-max labels', () => { - const result = scaleUpModule.parseEc2OverrideConfig(['ghr-ec2-vcpu-count-min:2', 'ghr-ec2-vcpu-count-max:8']); + const result = parseEc2OverrideConfig(['ghr-ec2-vcpu-count-min:2', 'ghr-ec2-vcpu-count-max:8']); expect(result?.InstanceRequirements?.VCpuCount?.Min).toBe(2); expect(result?.InstanceRequirements?.VCpuCount?.Max).toBe(8); }); it('should parse memory-mib-min label', () => { - const result = scaleUpModule.parseEc2OverrideConfig(['ghr-ec2-memory-mib-min:8192']); + const result = parseEc2OverrideConfig(['ghr-ec2-memory-mib-min:8192']); expect(result?.InstanceRequirements?.MemoryMiB?.Min).toBe(8192); }); it('should parse memory-mib-max label', () => { - const result = scaleUpModule.parseEc2OverrideConfig(['ghr-ec2-memory-mib-max:32768']); + const result = parseEc2OverrideConfig(['ghr-ec2-memory-mib-max:32768']); expect(result?.InstanceRequirements?.MemoryMiB?.Max).toBe(32768); }); it('should parse both memory-mib-min and memory-mib-max labels', () => { - const result = scaleUpModule.parseEc2OverrideConfig([ - 'ghr-ec2-memory-mib-min:16384', - 'ghr-ec2-memory-mib-max:65536', - ]); + const result = parseEc2OverrideConfig(['ghr-ec2-memory-mib-min:16384', 'ghr-ec2-memory-mib-max:65536']); expect(result?.InstanceRequirements?.MemoryMiB?.Min).toBe(16384); expect(result?.InstanceRequirements?.MemoryMiB?.Max).toBe(65536); }); it('should parse memory-gib-per-vcpu-min label', () => { - const result = scaleUpModule.parseEc2OverrideConfig(['ghr-ec2-memory-gib-per-vcpu-min:2']); + const result = parseEc2OverrideConfig(['ghr-ec2-memory-gib-per-vcpu-min:2']); expect(result?.InstanceRequirements?.MemoryGiBPerVCpu?.Min).toBe(2); }); it('should parse memory-gib-per-vcpu-max label', () => { - const result = scaleUpModule.parseEc2OverrideConfig(['ghr-ec2-memory-gib-per-vcpu-max:8']); + const result = parseEc2OverrideConfig(['ghr-ec2-memory-gib-per-vcpu-max:8']); expect(result?.InstanceRequirements?.MemoryGiBPerVCpu?.Max).toBe(8); }); it('should parse combined vCPU and memory requirements', () => { - const result = scaleUpModule.parseEc2OverrideConfig([ + const result = parseEc2OverrideConfig([ 'ghr-ec2-vcpu-count-min:8', 'ghr-ec2-vcpu-count-max:32', 'ghr-ec2-memory-mib-min:32768', @@ -2983,118 +3074,115 @@ describe('parseEc2OverrideConfig', () => { describe('Instance Requirements - CPU and Performance', () => { it('should parse cpu-manufacturers as single value', () => { - const result = scaleUpModule.parseEc2OverrideConfig(['ghr-ec2-cpu-manufacturers:intel']); + const result = parseEc2OverrideConfig(['ghr-ec2-cpu-manufacturers:intel']); expect(result?.InstanceRequirements?.CpuManufacturers).toEqual(['intel']); }); it('should parse cpu-manufacturers as semicolon-separated list', () => { - const result = scaleUpModule.parseEc2OverrideConfig(['ghr-ec2-cpu-manufacturers:intel;amd']); + const result = parseEc2OverrideConfig(['ghr-ec2-cpu-manufacturers:intel;amd']); expect(result?.InstanceRequirements?.CpuManufacturers).toEqual(['intel', 'amd']); }); it('should parse instance-generations as single value', () => { - const result = scaleUpModule.parseEc2OverrideConfig(['ghr-ec2-instance-generations:current']); + const result = parseEc2OverrideConfig(['ghr-ec2-instance-generations:current']); expect(result?.InstanceRequirements?.InstanceGenerations).toEqual(['current']); }); it('should parse instance-generations as semicolon-separated list', () => { - const result = scaleUpModule.parseEc2OverrideConfig(['ghr-ec2-instance-generations:current;previous']); + const result = parseEc2OverrideConfig(['ghr-ec2-instance-generations:current;previous']); expect(result?.InstanceRequirements?.InstanceGenerations).toEqual(['current', 'previous']); }); it('should parse excluded-instance-types as single value', () => { - const result = scaleUpModule.parseEc2OverrideConfig(['ghr-ec2-excluded-instance-types:t2.micro']); + const result = parseEc2OverrideConfig(['ghr-ec2-excluded-instance-types:t2.micro']); expect(result?.InstanceRequirements?.ExcludedInstanceTypes).toEqual(['t2.micro']); }); it('should parse excluded-instance-types as semicolon-separated list', () => { - const result = scaleUpModule.parseEc2OverrideConfig(['ghr-ec2-excluded-instance-types:t2.micro;t2.small']); + const result = parseEc2OverrideConfig(['ghr-ec2-excluded-instance-types:t2.micro;t2.small']); expect(result?.InstanceRequirements?.ExcludedInstanceTypes).toEqual(['t2.micro', 't2.small']); }); it('should parse allowed-instance-types as single value', () => { - const result = scaleUpModule.parseEc2OverrideConfig(['ghr-ec2-allowed-instance-types:c5.xlarge']); + const result = parseEc2OverrideConfig(['ghr-ec2-allowed-instance-types:c5.xlarge']); expect(result?.InstanceRequirements?.AllowedInstanceTypes).toEqual(['c5.xlarge']); }); it('should parse allowed-instance-types as semicolon-separated list', () => { - const result = scaleUpModule.parseEc2OverrideConfig(['ghr-ec2-allowed-instance-types:c5.xlarge;c5.2xlarge']); + const result = parseEc2OverrideConfig(['ghr-ec2-allowed-instance-types:c5.xlarge;c5.2xlarge']); expect(result?.InstanceRequirements?.AllowedInstanceTypes).toEqual(['c5.xlarge', 'c5.2xlarge']); }); it('should parse burstable-performance label', () => { - const result = scaleUpModule.parseEc2OverrideConfig(['ghr-ec2-burstable-performance:included']); + const result = parseEc2OverrideConfig(['ghr-ec2-burstable-performance:included']); expect(result?.InstanceRequirements?.BurstablePerformance).toBe('included'); }); it('should parse bare-metal label', () => { - const result = scaleUpModule.parseEc2OverrideConfig(['ghr-ec2-bare-metal:excluded']); + const result = parseEc2OverrideConfig(['ghr-ec2-bare-metal:excluded']); expect(result?.InstanceRequirements?.BareMetal).toBe('excluded'); }); }); describe('Instance Requirements - Accelerators', () => { it('should parse accelerator-count-min label', () => { - const result = scaleUpModule.parseEc2OverrideConfig(['ghr-ec2-accelerator-count-min:1']); + const result = parseEc2OverrideConfig(['ghr-ec2-accelerator-count-min:1']); expect(result?.InstanceRequirements?.AcceleratorCount?.Min).toBe(1); }); it('should parse accelerator-count-max label', () => { - const result = scaleUpModule.parseEc2OverrideConfig(['ghr-ec2-accelerator-count-max:4']); + const result = parseEc2OverrideConfig(['ghr-ec2-accelerator-count-max:4']); expect(result?.InstanceRequirements?.AcceleratorCount?.Max).toBe(4); }); it('should parse both accelerator-count-min and accelerator-count-max', () => { - const result = scaleUpModule.parseEc2OverrideConfig([ - 'ghr-ec2-accelerator-count-min:1', - 'ghr-ec2-accelerator-count-max:2', - ]); + const result = parseEc2OverrideConfig(['ghr-ec2-accelerator-count-min:1', 'ghr-ec2-accelerator-count-max:2']); expect(result?.InstanceRequirements?.AcceleratorCount?.Min).toBe(1); expect(result?.InstanceRequirements?.AcceleratorCount?.Max).toBe(2); }); it('should parse accelerator-types as single value', () => { - const result = scaleUpModule.parseEc2OverrideConfig(['ghr-ec2-accelerator-types:gpu']); + const result = parseEc2OverrideConfig(['ghr-ec2-accelerator-types:gpu']); expect(result?.InstanceRequirements?.AcceleratorTypes).toEqual(['gpu']); }); it('should parse accelerator-types as semicolon-separated list', () => { - const result = scaleUpModule.parseEc2OverrideConfig(['ghr-ec2-accelerator-types:gpu;fpga']); + const result = parseEc2OverrideConfig(['ghr-ec2-accelerator-types:gpu;fpga']); expect(result?.InstanceRequirements?.AcceleratorTypes).toEqual(['gpu', 'fpga']); }); it('should parse accelerator-manufacturers as single value', () => { - const result = scaleUpModule.parseEc2OverrideConfig(['ghr-ec2-accelerator-manufacturers:nvidia']); + const result = parseEc2OverrideConfig(['ghr-ec2-accelerator-manufacturers:nvidia']); expect(result?.InstanceRequirements?.AcceleratorManufacturers).toEqual(['nvidia']); }); it('should parse accelerator-manufacturers as semicolon-separated list', () => { - const result = scaleUpModule.parseEc2OverrideConfig(['ghr-ec2-accelerator-manufacturers:nvidia;amd']); + const result = parseEc2OverrideConfig(['ghr-ec2-accelerator-manufacturers:nvidia;amd']); expect(result?.InstanceRequirements?.AcceleratorManufacturers).toEqual(['nvidia', 'amd']); }); it('should parse accelerator-names as single value', () => { - const result = scaleUpModule.parseEc2OverrideConfig(['ghr-ec2-accelerator-names:a100']); + const result = parseEc2OverrideConfig(['ghr-ec2-accelerator-names:a100']); expect(result?.InstanceRequirements?.AcceleratorNames).toEqual(['a100']); }); it('should parse accelerator-names as semicolon-separated list', () => { - const result = scaleUpModule.parseEc2OverrideConfig(['ghr-ec2-accelerator-names:a100;v100']); + const result = parseEc2OverrideConfig(['ghr-ec2-accelerator-names:a100;v100']); expect(result?.InstanceRequirements?.AcceleratorNames).toEqual(['a100', 'v100']); }); it('should parse accelerator-total-memory-mib-min label', () => { - const result = scaleUpModule.parseEc2OverrideConfig(['ghr-ec2-accelerator-total-memory-mib-min:8192']); + const result = parseEc2OverrideConfig(['ghr-ec2-accelerator-total-memory-mib-min:8192']); expect(result?.InstanceRequirements?.AcceleratorTotalMemoryMiB?.Min).toBe(8192); }); it('should parse accelerator-total-memory-mib-max label', () => { - const result = scaleUpModule.parseEc2OverrideConfig(['ghr-ec2-accelerator-total-memory-mib-max:40960']); + const result = parseEc2OverrideConfig(['ghr-ec2-accelerator-total-memory-mib-max:40960']); expect(result?.InstanceRequirements?.AcceleratorTotalMemoryMiB?.Max).toBe(40960); }); it('should parse combined accelerator requirements', () => { - const result = scaleUpModule.parseEc2OverrideConfig([ + const result = parseEc2OverrideConfig([ 'ghr-ec2-accelerator-count-min:1', 'ghr-ec2-accelerator-count-max:2', 'ghr-ec2-accelerator-types:gpu', @@ -3109,113 +3197,105 @@ describe('parseEc2OverrideConfig', () => { describe('Instance Requirements - Network and Storage', () => { it('should parse network-interface-count-min label', () => { - const result = scaleUpModule.parseEc2OverrideConfig(['ghr-ec2-network-interface-count-min:2']); + const result = parseEc2OverrideConfig(['ghr-ec2-network-interface-count-min:2']); expect(result?.InstanceRequirements?.NetworkInterfaceCount?.Min).toBe(2); }); it('should parse network-interface-count-max label', () => { - const result = scaleUpModule.parseEc2OverrideConfig(['ghr-ec2-network-interface-count-max:4']); + const result = parseEc2OverrideConfig(['ghr-ec2-network-interface-count-max:4']); expect(result?.InstanceRequirements?.NetworkInterfaceCount?.Max).toBe(4); }); it('should parse network-bandwidth-gbps-min label', () => { - const result = scaleUpModule.parseEc2OverrideConfig(['ghr-ec2-network-bandwidth-gbps-min:5']); + const result = parseEc2OverrideConfig(['ghr-ec2-network-bandwidth-gbps-min:5']); expect(result?.InstanceRequirements?.NetworkBandwidthGbps?.Min).toBe(5); }); it('should parse network-bandwidth-gbps-max label', () => { - const result = scaleUpModule.parseEc2OverrideConfig(['ghr-ec2-network-bandwidth-gbps-max:25']); + const result = parseEc2OverrideConfig(['ghr-ec2-network-bandwidth-gbps-max:25']); expect(result?.InstanceRequirements?.NetworkBandwidthGbps?.Max).toBe(25); }); it('should parse local-storage label', () => { - const result = scaleUpModule.parseEc2OverrideConfig(['ghr-ec2-local-storage:included']); + const result = parseEc2OverrideConfig(['ghr-ec2-local-storage:included']); expect(result?.InstanceRequirements?.LocalStorage).toBe('included'); }); it('should parse local-storage-types as single value', () => { - const result = scaleUpModule.parseEc2OverrideConfig(['ghr-ec2-local-storage-types:ssd']); + const result = parseEc2OverrideConfig(['ghr-ec2-local-storage-types:ssd']); expect(result?.InstanceRequirements?.LocalStorageTypes).toEqual(['ssd']); }); it('should parse local-storage-types as semicolon-separated list', () => { - const result = scaleUpModule.parseEc2OverrideConfig(['ghr-ec2-local-storage-types:hdd;ssd']); + const result = parseEc2OverrideConfig(['ghr-ec2-local-storage-types:hdd;ssd']); expect(result?.InstanceRequirements?.LocalStorageTypes).toEqual(['hdd', 'ssd']); }); it('should parse total-local-storage-gb-min label', () => { - const result = scaleUpModule.parseEc2OverrideConfig(['ghr-ec2-total-local-storage-gb-min:100']); + const result = parseEc2OverrideConfig(['ghr-ec2-total-local-storage-gb-min:100']); expect(result?.InstanceRequirements?.TotalLocalStorageGB?.Min).toBe(100); }); it('should parse total-local-storage-gb-max label', () => { - const result = scaleUpModule.parseEc2OverrideConfig(['ghr-ec2-total-local-storage-gb-max:1000']); + const result = parseEc2OverrideConfig(['ghr-ec2-total-local-storage-gb-max:1000']); expect(result?.InstanceRequirements?.TotalLocalStorageGB?.Max).toBe(1000); }); it('should parse baseline-ebs-bandwidth-mbps-min label', () => { - const result = scaleUpModule.parseEc2OverrideConfig(['ghr-ec2-baseline-ebs-bandwidth-mbps-min:500']); + const result = parseEc2OverrideConfig(['ghr-ec2-baseline-ebs-bandwidth-mbps-min:500']); expect(result?.InstanceRequirements?.BaselineEbsBandwidthMbps?.Min).toBe(500); }); it('should parse baseline-ebs-bandwidth-mbps-max label', () => { - const result = scaleUpModule.parseEc2OverrideConfig(['ghr-ec2-baseline-ebs-bandwidth-mbps-max:2000']); + const result = parseEc2OverrideConfig(['ghr-ec2-baseline-ebs-bandwidth-mbps-max:2000']); expect(result?.InstanceRequirements?.BaselineEbsBandwidthMbps?.Max).toBe(2000); }); }); describe('Instance Requirements - Pricing and Other', () => { it('should parse spot-max-price-percentage-over-lowest-price label', () => { - const result = scaleUpModule.parseEc2OverrideConfig(['ghr-ec2-spot-max-price-percentage-over-lowest-price:50']); + const result = parseEc2OverrideConfig(['ghr-ec2-spot-max-price-percentage-over-lowest-price:50']); expect(result?.InstanceRequirements?.SpotMaxPricePercentageOverLowestPrice).toBe(50); }); it('should parse on-demand-max-price-percentage-over-lowest-price label', () => { - const result = scaleUpModule.parseEc2OverrideConfig([ - 'ghr-ec2-on-demand-max-price-percentage-over-lowest-price:75', - ]); + const result = parseEc2OverrideConfig(['ghr-ec2-on-demand-max-price-percentage-over-lowest-price:75']); expect(result?.InstanceRequirements?.OnDemandMaxPricePercentageOverLowestPrice).toBe(75); }); it('should parse max-spot-price-as-percentage-of-optimal-on-demand-price label', () => { - const result = scaleUpModule.parseEc2OverrideConfig([ - 'ghr-ec2-max-spot-price-as-percentage-of-optimal-on-demand-price:60', - ]); + const result = parseEc2OverrideConfig(['ghr-ec2-max-spot-price-as-percentage-of-optimal-on-demand-price:60']); expect(result?.InstanceRequirements?.MaxSpotPriceAsPercentageOfOptimalOnDemandPrice).toBe(60); }); it('should parse require-hibernate-support label as boolean true', () => { - const result = scaleUpModule.parseEc2OverrideConfig(['ghr-ec2-require-hibernate-support:true']); + const result = parseEc2OverrideConfig(['ghr-ec2-require-hibernate-support:true']); expect(result?.InstanceRequirements?.RequireHibernateSupport).toBe(true); }); it('should parse require-hibernate-support label as boolean false', () => { - const result = scaleUpModule.parseEc2OverrideConfig(['ghr-ec2-require-hibernate-support:false']); + const result = parseEc2OverrideConfig(['ghr-ec2-require-hibernate-support:false']); expect(result?.InstanceRequirements?.RequireHibernateSupport).toBe(false); }); it('should parse require-encryption-in-transit label as boolean true', () => { - const result = scaleUpModule.parseEc2OverrideConfig(['ghr-ec2-require-encryption-in-transit:true']); + const result = parseEc2OverrideConfig(['ghr-ec2-require-encryption-in-transit:true']); expect(result?.InstanceRequirements?.RequireEncryptionInTransit).toBe(true); }); it('should parse require-encryption-in-transit label as boolean false', () => { - const result = scaleUpModule.parseEc2OverrideConfig(['ghr-ec2-require-encryption-in-transit:false']); + const result = parseEc2OverrideConfig(['ghr-ec2-require-encryption-in-transit:false']); expect(result?.InstanceRequirements?.RequireEncryptionInTransit).toBe(false); }); it('should parse baseline-performance-factors-cpu-reference-families label', () => { - const result = scaleUpModule.parseEc2OverrideConfig([ - 'ghr-ec2-baseline-performance-factors-cpu-reference-families:intel', - ]); + const result = parseEc2OverrideConfig(['ghr-ec2-baseline-performance-factors-cpu-reference-families:intel']); expect(result?.InstanceRequirements?.BaselinePerformanceFactors?.Cpu?.References?.[0]?.InstanceFamily).toBe( 'intel', ); }); it('should parse baseline-performance-factors-cpu-reference-families list label', () => { - const result = scaleUpModule.parseEc2OverrideConfig([ - 'ghr-ec2-baseline-performance-factors-cpu-reference-families:intel;amd', - ]); + const result = parseEc2OverrideConfig(['ghr-ec2-baseline-performance-factors-cpu-reference-families:intel;amd']); expect(result?.InstanceRequirements?.BaselinePerformanceFactors?.Cpu?.References?.[0]?.InstanceFamily).toBe( 'intel', ); @@ -3227,17 +3307,17 @@ describe('parseEc2OverrideConfig', () => { describe('Edge Cases', () => { it('should return undefined when empty array is provided', () => { - const result = scaleUpModule.parseEc2OverrideConfig([]); + const result = parseEc2OverrideConfig([]); expect(result).toBeUndefined(); }); it('should return undefined when no ghr-ec2 labels are provided', () => { - const result = scaleUpModule.parseEc2OverrideConfig(['self-hosted', 'linux', 'x64']); + const result = parseEc2OverrideConfig(['self-hosted', 'linux', 'x64']); expect(result).toBeUndefined(); }); it('should ignore non-ghr-ec2 labels and only parse ghr-ec2 labels', () => { - const result = scaleUpModule.parseEc2OverrideConfig([ + const result = parseEc2OverrideConfig([ 'self-hosted', 'ghr-ec2-instance-type:m5.large', 'linux', @@ -3248,7 +3328,7 @@ describe('parseEc2OverrideConfig', () => { }); it('should handle labels with colons in values (ARNs)', () => { - const result = scaleUpModule.parseEc2OverrideConfig([ + const result = parseEc2OverrideConfig([ 'ghr-ec2-ebs-kms-key-id:arn:aws:kms:us-east-1:123456789012:key/abc-def-ghi', ]); expect(result?.BlockDeviceMappings?.[0]?.Ebs?.KmsKeyId).toBe( @@ -3257,7 +3337,7 @@ describe('parseEc2OverrideConfig', () => { }); it('should handle labels with colons in placement ARNs', () => { - const result = scaleUpModule.parseEc2OverrideConfig([ + const result = parseEc2OverrideConfig([ 'ghr-ec2-placement-host-resource-group-arn:arn:aws:ec2:us-west-2:123456789012:host-resource-group/hrg-abc123', ]); expect(result?.Placement?.HostResourceGroupArn).toBe( @@ -3266,19 +3346,19 @@ describe('parseEc2OverrideConfig', () => { }); it('should handle labels without values gracefully', () => { - const result = scaleUpModule.parseEc2OverrideConfig(['ghr-ec2-instance-type:', 'ghr-ec2-max-price:0.50']); + const result = parseEc2OverrideConfig(['ghr-ec2-instance-type:', 'ghr-ec2-max-price:0.50']); expect(result?.InstanceType).toBeUndefined(); expect(result?.MaxPrice).toBe('0.50'); }); it('should handle malformed labels (no colon) gracefully', () => { - const result = scaleUpModule.parseEc2OverrideConfig(['ghr-ec2-instance-type-m5-large', 'ghr-ec2-max-price:0.50']); + const result = parseEc2OverrideConfig(['ghr-ec2-instance-type-m5-large', 'ghr-ec2-max-price:0.50']); expect(result?.MaxPrice).toBe('0.50'); expect(result?.InstanceType).toBeUndefined(); }); it('should handle numeric strings correctly for number fields', () => { - const result = scaleUpModule.parseEc2OverrideConfig([ + const result = parseEc2OverrideConfig([ 'ghr-ec2-priority:5', 'ghr-ec2-weighted-capacity:10', 'ghr-ec2-vcpu-count-min:4', @@ -3289,7 +3369,7 @@ describe('parseEc2OverrideConfig', () => { }); it('should handle boolean strings correctly for boolean fields', () => { - const result = scaleUpModule.parseEc2OverrideConfig([ + const result = parseEc2OverrideConfig([ 'ghr-ec2-ebs-encrypted:true', 'ghr-ec2-ebs-delete-on-termination:false', 'ghr-ec2-require-hibernate-support:true', @@ -3300,17 +3380,17 @@ describe('parseEc2OverrideConfig', () => { }); it('should handle floating point numbers in max-price', () => { - const result = scaleUpModule.parseEc2OverrideConfig(['ghr-ec2-max-price:0.12345']); + const result = parseEc2OverrideConfig(['ghr-ec2-max-price:0.12345']); expect(result?.MaxPrice).toBe('0.12345'); }); it('should handle whitespace in semicolon-separated lists', () => { - const result = scaleUpModule.parseEc2OverrideConfig(['ghr-ec2-cpu-manufacturers: intel ; amd ']); + const result = parseEc2OverrideConfig(['ghr-ec2-cpu-manufacturers: intel ; amd ']); expect(result?.InstanceRequirements?.CpuManufacturers).toEqual([' intel ', ' amd ']); }); it('should return config with all parsed labels', () => { - const result = scaleUpModule.parseEc2OverrideConfig([ + const result = parseEc2OverrideConfig([ 'ghr-ec2-instance-type:c5.xlarge', 'ghr-ec2-vcpu-count-min:4', 'ghr-ec2-memory-mib-min:8192', @@ -3327,7 +3407,7 @@ describe('parseEc2OverrideConfig', () => { describe('Complex Scenarios', () => { it('should handle comprehensive EC2 configuration with all categories', () => { - const result = scaleUpModule.parseEc2OverrideConfig([ + const result = parseEc2OverrideConfig([ // Basic Fleet 'ghr-ec2-instance-type:r5.2xlarge', 'ghr-ec2-max-price:0.75', @@ -3363,7 +3443,7 @@ describe('parseEc2OverrideConfig', () => { }); it('should handle GPU instance configuration', () => { - const result = scaleUpModule.parseEc2OverrideConfig([ + const result = parseEc2OverrideConfig([ 'ghr-ec2-accelerator-count-min:1', 'ghr-ec2-accelerator-count-max:4', 'ghr-ec2-accelerator-types:gpu', @@ -3381,7 +3461,7 @@ describe('parseEc2OverrideConfig', () => { }); it('should handle network-optimized instance configuration', () => { - const result = scaleUpModule.parseEc2OverrideConfig([ + const result = parseEc2OverrideConfig([ 'ghr-ec2-network-interface-count-min:2', 'ghr-ec2-network-interface-count-max:8', 'ghr-ec2-network-bandwidth-gbps-min:10', @@ -3397,7 +3477,7 @@ describe('parseEc2OverrideConfig', () => { }); it('should handle storage-optimized instance configuration', () => { - const result = scaleUpModule.parseEc2OverrideConfig([ + const result = parseEc2OverrideConfig([ 'ghr-ec2-local-storage:included', 'ghr-ec2-local-storage-types:ssd', 'ghr-ec2-total-local-storage-gb-min:500', @@ -3411,7 +3491,7 @@ describe('parseEc2OverrideConfig', () => { }); it('should handle spot instance configuration with pricing', () => { - const result = scaleUpModule.parseEc2OverrideConfig([ + const result = parseEc2OverrideConfig([ 'ghr-ec2-max-price:0.50', 'ghr-ec2-spot-max-price-percentage-over-lowest-price:100', 'ghr-ec2-on-demand-max-price-percentage-over-lowest-price:150', @@ -3424,96 +3504,11 @@ describe('parseEc2OverrideConfig', () => { }); }); -describe('useDedicatedHost', () => { - beforeEach(() => { - process.env.ENABLE_ORGANIZATION_RUNNERS = 'true'; - process.env.ENABLE_EPHEMERAL_RUNNERS = 'true'; - process.env.RUNNER_NAME_PREFIX = 'unit-test-'; - process.env.RUNNER_GROUP_NAME = 'Default'; - process.env.SSM_CONFIG_PATH = '/github-action-runners/default/runners/config'; - process.env.SSM_TOKEN_PATH = '/github-action-runners/default/runners/config'; - process.env.RUNNER_LABELS = 'label1,label2'; - }); - - it('defaults to false when USE_DEDICATED_HOST env var is not set', async () => { - delete process.env.USE_DEDICATED_HOST; - await scaleUpModule.scaleUp(TEST_DATA); - expect(createRunner).toHaveBeenCalledWith(expect.objectContaining({ useDedicatedHost: false })); - }); - - it('is true when USE_DEDICATED_HOST is "true"', async () => { - process.env.USE_DEDICATED_HOST = 'true'; - await scaleUpModule.scaleUp(TEST_DATA); - expect(createRunner).toHaveBeenCalledWith(expect.objectContaining({ useDedicatedHost: true })); - }); +describe('runner provider selection', () => { + it('rejects unsupported scale-up provider types', async () => { + process.env.RUNNER_PROVIDER_TYPE = 'microvm'; - it('is false when USE_DEDICATED_HOST is "false"', async () => { - process.env.USE_DEDICATED_HOST = 'false'; - await scaleUpModule.scaleUp(TEST_DATA); - expect(createRunner).toHaveBeenCalledWith(expect.objectContaining({ useDedicatedHost: false })); + await expect(scaleUpModule.scaleUp(TEST_DATA)).rejects.toThrow("Unsupported runner provider type 'microvm'"); + expect(mockedAppAuth).not.toHaveBeenCalled(); }); }); - -function defaultOctokitMockImpl() { - mockOctokit.actions.getJobForWorkflowRun.mockImplementation(() => ({ - data: { - status: 'queued', - }, - })); - mockOctokit.paginate.mockImplementation(() => [ - { - id: 1, - name: 'Default', - }, - ]); - mockOctokit.actions.generateRunnerJitconfigForOrg.mockImplementation(({ labels }: { labels: string[] }) => ({ - data: { - runner: { id: 9876543210, labels: labels.map((name: string) => ({ name })) }, - encoded_jit_config: 'TEST_JIT_CONFIG_ORG', - }, - })); - mockOctokit.actions.generateRunnerJitconfigForRepo.mockImplementation(({ labels }: { labels: string[] }) => ({ - data: { - runner: { id: 9876543210, labels: labels.map((name: string) => ({ name })) }, - encoded_jit_config: 'TEST_JIT_CONFIG_REPO', - }, - })); - mockOctokit.checks.get.mockImplementation(() => ({ - data: { - status: 'queued', - }, - })); - - const mockTokenReturnValue = { - data: { - token: '1234abcd', - }, - }; - const mockInstallationIdReturnValueOrgs = { - data: { - id: TEST_DATA_SINGLE.installationId, - }, - }; - const mockInstallationIdReturnValueRepos = { - data: { - id: TEST_DATA_SINGLE.installationId, - }, - }; - - mockOctokit.actions.createRegistrationTokenForOrg.mockImplementation(() => mockTokenReturnValue); - mockOctokit.actions.createRegistrationTokenForRepo.mockImplementation(() => mockTokenReturnValue); - mockOctokit.apps.getOrgInstallation.mockImplementation(() => mockInstallationIdReturnValueOrgs); - mockOctokit.apps.getRepoInstallation.mockImplementation(() => mockInstallationIdReturnValueRepos); -} - -function defaultSSMGetParameterMockImpl() { - mockSSMgetParameter.mockImplementation(async (name: string) => { - if (name === `${process.env.SSM_CONFIG_PATH}/runner-group/${process.env.RUNNER_GROUP_NAME}`) { - return '1'; - } else if (name === `${process.env.PARAMETER_GITHUB_APP_ID_NAME}`) { - return `${process.env.GITHUB_APP_ID}`; - } else { - throw new Error(`ParameterNotFound: ${name}`); - } - }); -} 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 35d73c9364..f6d039cb34 100644 --- a/lambdas/functions/control-plane/src/scale-runners/scale-up.ts +++ b/lambdas/functions/control-plane/src/scale-runners/scale-up.ts @@ -1,206 +1,26 @@ +import { addPersistentContextToChildLogger, createChildLogger } from '@aws-github-runner/aws-powertools-util'; +import { resolveRunnerProviderType } from '@aws-github-runner/runner-provider'; import { Octokit } from '@octokit/rest'; -import { - addPersistentContextToChildLogger, - createChildLogger, - getTracedAWSV3Client, -} from '@aws-github-runner/aws-powertools-util'; -import { getParameter, putParameter } from '@aws-github-runner/aws-ssm-util'; import yn from 'yn'; import { createGithubAppAuth, createGithubInstallationAuth, createOctokitClient } from '../github/auth'; -import { createRunner, listEC2Runners, tag, terminateRunner } from './../aws/runners'; -import { Ec2OverrideConfig, RunnerInputParameters } from './../aws/runners.d'; -import { metricGitHubAppRateLimit } from '../github/rate-limit'; -import { publishRetryMessage } from './job-retry'; +import { createScaleUpRunnerProvider } from '../runner-provider-registry'; import { - _InstanceType, - Tenancy, - VolumeType, - CpuManufacturer, - InstanceGeneration, - BurstablePerformance, - BareMetal, - AcceleratorType, - AcceleratorManufacturer, - AcceleratorName, - LocalStorage, - LocalStorageType, - Placement, - BaselinePerformanceFactorsRequest, - FleetEbsBlockDeviceRequest, - CpuPerformanceFactorRequest, - PerformanceFactorReferenceRequest, - FleetBlockDeviceMappingRequest, - InstanceRequirementsRequest, - VCpuCountRangeRequest, - MemoryMiBRequest, - MemoryGiBPerVCpuRequest, - AcceleratorCountRequest, - AcceleratorTotalMemoryMiBRequest, - NetworkInterfaceCountRequest, - NetworkBandwidthGbpsRequest, - TotalLocalStorageGBRequest, - BaselineEbsBandwidthMbpsRequest, - DescribeLaunchTemplateVersionsCommand, - EC2Client, - Tag, -} from '@aws-sdk/client-ec2'; + getGitHubEnterpriseApiUrl, + getInstallationId, + resolveInstallationId, + isJobQueued, + validateSsmParameterStoreTags, +} from './github-runner'; +import { publishRetryMessage } from './job-retry'; +import type { + ActionRequestMessage, + ActionRequestMessageRetry, + ActionRequestMessageSQS, + CreateGitHubRunnerConfig, +} from './types'; const logger = createChildLogger('scale-up'); -const RUNNER_LABELS_TAG_KEY = 'ghr:runner_labels'; -const RUNNER_LABELS_TAG_VALUE_SEPARATOR = ','; -const EC2_OVERRIDE_LIST_VALUE_SEPARATOR = ';'; -export const EC2_TAG_VALUE_MAX_LENGTH = 256; -export const RUNNER_LABELS_TAG_MAX_COUNT = 5; - -export type LambdaRunnerSource = 'scale-up-lambda' | 'pool-lambda'; - -export interface RunnerGroup { - name: string; - id: number; -} - -interface EphemeralRunnerConfig { - runnerName: string; - runnerGroupId: number; - runnerLabels: string[]; -} - -export interface ActionRequestMessage { - id: number; - eventType: 'check_run' | 'workflow_job'; - repositoryName: string; - repositoryOwner: string; - installationId: number; - repoOwnerType: string; - retryCounter?: number; - labels?: string[]; -} - -export interface ActionRequestMessageSQS extends ActionRequestMessage { - messageId: string; -} - -export interface ActionRequestMessageRetry extends ActionRequestMessage { - retryCounter: number; -} - -interface CreateGitHubRunnerConfig { - ephemeral: boolean; - ghesBaseUrl: string; - enableJitConfig: boolean; - runnerLabels: string; - runnerGroup: string; - runnerNamePrefix: string; - runnerOwner: string; - runnerType: 'Org' | 'Repo'; - disableAutoUpdate: boolean; - ssmTokenPath: string; - ssmConfigPath: string; - ssmParameterStoreTags: { Key: string; Value: string }[]; -} - -interface CreateEC2RunnerConfig { - environment: string; - subnets: string[]; - launchTemplateName: string; - ec2instanceCriteria: RunnerInputParameters['ec2instanceCriteria']; - ec2OverrideConfig?: RunnerInputParameters['ec2OverrideConfig']; - numberOfRunners?: number; - amiIdSsmParameterName?: string; - tracingEnabled?: boolean; - onDemandFailoverOnError?: string[]; - scaleErrors: string[]; - useDedicatedHost?: boolean; -} - -function generateRunnerServiceConfig(githubRunnerConfig: CreateGitHubRunnerConfig, token: string) { - const config = [ - `--url ${githubRunnerConfig.ghesBaseUrl ?? 'https://github.com'}/${githubRunnerConfig.runnerOwner}`, - `--token ${token}`, - ]; - - if (githubRunnerConfig.runnerLabels) { - config.push(`--labels ${quoteRunnerLabelsForShell(githubRunnerConfig.runnerLabels)}`.trim()); - } - - if (githubRunnerConfig.disableAutoUpdate) { - config.push('--disableupdate'); - } - - if (githubRunnerConfig.runnerType === 'Org' && githubRunnerConfig.runnerGroup !== undefined) { - config.push(`--runnergroup ${githubRunnerConfig.runnerGroup}`); - } - - if (githubRunnerConfig.ephemeral) { - config.push(`--ephemeral`); - } - - return config; -} - -function quoteRunnerLabelsForShell(labels: string): string { - return /[\s;&|<>()$`"'*?[\\\]{}!]/.test(labels) ? quoteShellArg(labels) : labels; -} - -function quoteShellArg(value: string): string { - return `'${value.replace(/'/g, `'\\''`)}'`; -} - -export function validateSsmParameterStoreTags(tagsJson: string): { Key: string; Value: string }[] { - try { - const tags = JSON.parse(tagsJson); - - if (!Array.isArray(tags)) { - throw new Error('Tags must be an array'); - } - - if (tags.length === 0) { - return []; - } - - tags.forEach((tag, index) => { - if (typeof tag !== 'object' || tag === null) { - throw new Error(`Tag at index ${index} must be an object`); - } - if (!tag.Key || typeof tag.Key !== 'string' || tag.Key.trim() === '') { - throw new Error(`Tag at index ${index} has missing or invalid 'Key' property`); - } - if (!Object.prototype.hasOwnProperty.call(tag, 'Value') || typeof tag.Value !== 'string') { - throw new Error(`Tag at index ${index} has missing or invalid 'Value' property`); - } - }); - - return tags; - } catch (err) { - logger.error('Invalid SSM_PARAMETER_STORE_TAGS format', { error: err }); - throw new Error(`Failed to parse SSM_PARAMETER_STORE_TAGS: ${(err as Error).message}`); - } -} - -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], - }); - - return registrationToken.data.token; -} - -function removeTokenFromLogging(config: string[]): string[] { - const result: string[] = []; - config.forEach((e) => { - if (e.startsWith('--token')) { - result.push('--token '); - } else { - result.push(e); - } - }); - return result; -} function getErrorStatus(error: unknown): number | undefined { if (typeof error !== 'object' || error === null) { @@ -211,185 +31,6 @@ function getErrorStatus(error: unknown): number | undefined { return errorWithStatus.status ?? errorWithStatus.response?.status; } -async function resolveInstallationId( - githubAppClient: Octokit, - enableOrgLevel: boolean, - payload: ActionRequestMessage, -): Promise { - return enableOrgLevel - ? ( - await githubAppClient.apps.getOrgInstallation({ - org: payload.repositoryOwner, - }) - ).data.id - : ( - await githubAppClient.apps.getRepoInstallation({ - owner: payload.repositoryOwner, - repo: payload.repositoryName, - }) - ).data.id; -} - -export async function getInstallationId( - githubAppClient: Octokit, - enableOrgLevel: boolean, - payload: ActionRequestMessage, -): Promise { - if (payload.installationId !== 0) { - return payload.installationId; - } - - return resolveInstallationId(githubAppClient, enableOrgLevel, payload); -} - -export async function isJobQueued(githubInstallationClient: Octokit, payload: ActionRequestMessage): Promise { - let isQueued = false; - if (payload.eventType === 'workflow_job') { - const jobForWorkflowRun = await githubInstallationClient.actions.getJobForWorkflowRun({ - job_id: payload.id, - owner: payload.repositoryOwner, - repo: payload.repositoryName, - }); - metricGitHubAppRateLimit(jobForWorkflowRun.headers); - isQueued = jobForWorkflowRun.data.status === 'queued'; - logger.debug(`The job ${payload.id} is${isQueued ? ' ' : 'not'} queued`); - } else { - throw Error(`Event ${payload.eventType} is not supported`); - } - return isQueued; -} - -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) { - 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 - try { - runnerGroup = await getParameter( - `${githubRunnerConfig.ssmConfigPath}/runner-group/${githubRunnerConfig.runnerGroup}`, - ); - } catch (err) { - logger.debug('Handling error:', err as Error); - logger.warn( - `SSM Parameter "${githubRunnerConfig.ssmConfigPath}/runner-group/${githubRunnerConfig.runnerGroup}" - for Runner group ${githubRunnerConfig.runnerGroup} does not exist`, - ); - } - if (runnerGroup === undefined) { - // get runner group id from GitHub - runnerGroupId = await getRunnerGroupByName(ghClient, githubRunnerConfig); - // store runner group id in SSM - try { - await putParameter( - `${githubRunnerConfig.ssmConfigPath}/runner-group/${githubRunnerConfig.runnerGroup}`, - runnerGroupId.toString(), - false, - { - tags: githubRunnerConfig.ssmParameterStoreTags, - }, - ); - } catch (err) { - logger.debug('Error storing runner group id in SSM Parameter Store', err as Error); - throw err; - } - } else { - runnerGroupId = parseInt(runnerGroup); - } - } - return runnerGroupId; -} - -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 runnerGroupId = runnerGroups.find((runnerGroup) => runnerGroup.name === githubRunnerConfig.runnerGroup)?.id; - - if (runnerGroupId === undefined) { - throw new Error(`Runner group ${githubRunnerConfig.runnerGroup} does not exist`); - } - - return runnerGroupId; -} - -export async function createRunners( - githubRunnerConfig: CreateGitHubRunnerConfig, - ec2RunnerConfig: CreateEC2RunnerConfig, - numberOfRunners: number, - ghClient: Octokit, - source: LambdaRunnerSource = 'scale-up-lambda', -): Promise { - const instances = await createRunner({ - runnerType: githubRunnerConfig.runnerType, - runnerOwner: githubRunnerConfig.runnerOwner, - numberOfRunners, - source, - ...ec2RunnerConfig, - }); - if (instances.length !== 0) { - const failedInstances = await createStartRunnerConfig(githubRunnerConfig, instances, ghClient); - - // Terminate instances that failed to get configured to avoid waste - if (failedInstances.length > 0) { - logger.warn('Terminating instances that failed to get configured', { - failedInstances, - failedCount: failedInstances.length, - }); - - for (const instanceId of failedInstances) { - try { - await terminateRunner(instanceId); - } catch (error) { - logger.error('Failed to terminate instance', { - instanceId, - error: error instanceof Error ? error.message : String(error), - }); - } - } - - // Remove failed instances from the returned list - return instances.filter((id) => !failedInstances.includes(id)); - } - } - - return instances; -} - -function generateRunnerLabelsTags(labels: string[]): Tag[] { - if (labels.length === 0) { - return []; - } - - const generatedTagValues = packRunnerLabelsTagValues(labels); - const tagValues = generatedTagValues.slice(0, RUNNER_LABELS_TAG_MAX_COUNT); - - if (generatedTagValues.length > RUNNER_LABELS_TAG_MAX_COUNT) { - logger.warn('GitHub runner label EC2 tags were truncated to avoid exceeding EC2 tag limits.', { - maxRunnerLabelsTagCount: RUNNER_LABELS_TAG_MAX_COUNT, - }); - } - - return tagValues.map((value, index) => ({ - Key: index === 0 ? RUNNER_LABELS_TAG_KEY : `${RUNNER_LABELS_TAG_KEY}:${index + 1}`, - Value: value, - })); -} - -function packRunnerLabelsTagValues(labels: string[]): string[] { - const runnerLabelsValue = labels.join(RUNNER_LABELS_TAG_VALUE_SEPARATOR); - const characters = Array.from(runnerLabelsValue); - const tagValues: string[] = []; - - for (let start = 0; start < characters.length; start += EC2_TAG_VALUE_MAX_LENGTH) { - tagValues.push(characters.slice(start, start + EC2_TAG_VALUE_MAX_LENGTH).join('')); - } - - return tagValues; -} - async function createGithubInstallationClient( githubAppClient: Octokit, enableOrgLevel: boolean, @@ -432,34 +73,19 @@ export async function scaleUp(payloads: ActionRequestMessageSQS[]): Promise) - : undefined; const enableJobQueuedCheck = yn(process.env.ENABLE_JOB_QUEUED_CHECK, { default: true }); - const amiIdSsmParameterName = process.env.AMI_ID_SSM_PARAMETER_NAME; const runnerNamePrefix = process.env.RUNNER_NAME_PREFIX || ''; const ssmConfigPath = process.env.SSM_CONFIG_PATH || ''; - const tracingEnabled = yn(process.env.POWERTOOLS_TRACE_ENABLED, { default: false }); - const onDemandFailoverOnError = process.env.ENABLE_ON_DEMAND_FAILOVER_FOR_ERRORS - ? (JSON.parse(process.env.ENABLE_ON_DEMAND_FAILOVER_FOR_ERRORS) as [string]) - : []; const ssmParameterStoreTags: { Key: string; Value: string }[] = process.env.SSM_PARAMETER_STORE_TAGS && process.env.SSM_PARAMETER_STORE_TAGS.trim() !== '' ? validateSsmParameterStoreTags(process.env.SSM_PARAMETER_STORE_TAGS) : []; - const scaleErrors = JSON.parse(process.env.SCALE_ERRORS) as [string]; - const useDedicatedHost = yn(process.env.USE_DEDICATED_HOST, { default: false }); + const runnerProviderType = resolveRunnerProviderType(process.env.RUNNER_PROVIDER_TYPE); + const runnerProvider = createScaleUpRunnerProvider(runnerProviderType); const { ghesApiUrl, ghesBaseUrl } = getGitHubEnterpriseApiUrl(); @@ -558,35 +184,19 @@ export async function scaleUp(payloads: ActionRequestMessageSQS[]): Promise 0 ? (messages[0].labels ?? []) : []; - const dynamicEC2Labels = messageLabels.map((l) => l.trim()).filter((l) => l.startsWith('ghr-ec2-')); - const nonEc2DynamicLabels = messageLabels - .map((l) => l.trim()) - .filter((l) => l.startsWith('ghr-') && !l.startsWith('ghr-ec2-')); - const allDynamicLabels = [...nonEc2DynamicLabels, ...dynamicEC2Labels]; + const preparedRunnerGroup = await runnerProvider.prepareGroup(messageLabels); + const dynamicLabels = preparedRunnerGroup.runnerLabels; - if (allDynamicLabels.length > 0) { - logger.debug('Dynamic labels present on message', { labels: allDynamicLabels }); + if (dynamicLabels.length > 0) { + logger.debug('Dynamic labels present on message', { labels: dynamicLabels }); groupRunnerLabels = groupRunnerLabels - ? `${groupRunnerLabels},${allDynamicLabels.join(',')}` - : allDynamicLabels.join(','); + ? `${groupRunnerLabels},${dynamicLabels.join(',')}` + : dynamicLabels.join(','); logger.debug('Updated runner labels', { runnerLabels: groupRunnerLabels }); - - if (dynamicEC2Labels.length > 0) { - const defaultBlockDeviceName = shouldLoadLaunchTemplateBlockDeviceName(dynamicEC2Labels) - ? await getDefaultBlockDeviceNameFromLaunchTemplate(launchTemplateName) - : undefined; - - ec2OverrideConfig = parseEc2OverrideConfig(dynamicEC2Labels, defaultBlockDeviceName); - if (ec2OverrideConfig) { - logger.debug('EC2 override config parsed from labels', { ec2OverrideConfig }); - } - } } for (const message of messages) { @@ -616,9 +226,11 @@ export async function scaleUp(payloads: ActionRequestMessageSQS[]): Promise 0) { + if (skippedRunnerCount > 0) { logger.info('Not all runners will be created for this group, maximum number of runners reached.', { desiredNewRunners: scaleUp, }); if (ephemeralEnabled) { - // This removes `missingInstanceCount` items from the start of the array + // This removes `skippedRunnerCount` items from the start of the array // so that, if we retry more messages later, we pick fresh ones. - const removedMessages = messages.splice(0, missingInstanceCount); + const removedMessages = messages.splice(0, skippedRunnerCount); removedMessages.forEach(({ messageId }) => rejectedMessageIds.add(messageId)); } - // No runners will be created, so skip calling the EC2 API. + // No runners will be created, so skip calling the provider. if (newRunners <= 0) { // Publish retry messages for messages that are not rejected for (const message of queuedMessages) { @@ -665,56 +277,41 @@ export async function scaleUp(payloads: ActionRequestMessageSQS[]): Promise rejectedMessageIds.add(messageId)); } @@ -729,524 +326,10 @@ export async function scaleUp(payloads: ActionRequestMessageSQS[]): Promise { - if (githubRunnerConfig.enableJitConfig && githubRunnerConfig.ephemeral) { - return await createJitConfig(githubRunnerConfig, instances, ghClient); - } else { - return await createRegistrationTokenConfig(githubRunnerConfig, instances, ghClient); - } -} - function isValidRepoOwnerTypeIfOrgLevelEnabled(payload: ActionRequestMessage, enableOrgLevel: boolean): boolean { return !(enableOrgLevel && payload.repoOwnerType !== 'Organization'); } -function addDelay(instances: string[]) { - const delay = async (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); - const ssmParameterStoreMaxThroughput = 40; - const isDelay = instances.length >= ssmParameterStoreMaxThroughput; - return { isDelay, delay }; -} - -/** - * Creates registration token configuration for non-ephemeral runners. - * - * @returns Empty array (this configuration method does not have failure cases) - */ -async function createRegistrationTokenConfig( - githubRunnerConfig: CreateGitHubRunnerConfig, - instances: string[], - ghClient: Octokit, -): Promise { - const { isDelay, delay } = addDelay(instances); - const token = await getGithubRunnerRegistrationToken(githubRunnerConfig, ghClient); - const runnerServiceConfig = generateRunnerServiceConfig(githubRunnerConfig, token); - - logger.debug('Runner service config for non-ephemeral runners', { - runner_service_config: removeTokenFromLogging(runnerServiceConfig), - }); - - for (const instance of instances) { - await putParameter(`${githubRunnerConfig.ssmTokenPath}/${instance}`, runnerServiceConfig.join(' '), true, { - tags: [{ Key: 'InstanceId', Value: instance }, ...githubRunnerConfig.ssmParameterStoreTags], - }); - if (isDelay) { - // Delay to prevent AWS ssm rate limits by being within the max throughput limit - await delay(25); - } - } - - return []; -} - -async function tagRunnerMetadata(instanceId: string, runnerId: string, runnerLabels: string[]): Promise { - const tags = [{ Key: 'ghr:github_runner_id', Value: runnerId }, ...generateRunnerLabelsTags(runnerLabels)]; - - try { - await tag(instanceId, tags); - } catch (e) { - logger.error(`Failed to mark runner '${instanceId}' with GitHub runner metadata.`, { error: e }); - } -} - -/** - * Creates JIT (Just-In-Time) configuration for ephemeral runners. - * Continues processing remaining instances even if some fail. - * - * @returns Array of instance IDs that failed to get JIT configuration - */ -async function createJitConfig( - githubRunnerConfig: CreateGitHubRunnerConfig, - instances: string[], - ghClient: Octokit, -): Promise { - const runnerGroupId = await getRunnerGroupId(githubRunnerConfig, ghClient); - const { isDelay, delay } = addDelay(instances); - const runnerLabels = githubRunnerConfig.runnerLabels.split(','); - const failedInstances: string[] = []; - - logger.debug(`Runner group id: ${runnerGroupId}`); - logger.debug(`Runner labels: ${runnerLabels}`); - for (const instance of instances) { - try { - // generate jit config for runner registration - const ephemeralRunnerConfig: EphemeralRunnerConfig = { - runnerName: `${githubRunnerConfig.runnerNamePrefix}${instance}`, - runnerGroupId: runnerGroupId, - runnerLabels: runnerLabels, - }; - logger.debug(`Runner name: ${ephemeralRunnerConfig.runnerName}`); - const runnerConfig = - githubRunnerConfig.runnerType === 'Org' - ? await ghClient.actions.generateRunnerJitconfigForOrg({ - org: githubRunnerConfig.runnerOwner, - name: ephemeralRunnerConfig.runnerName, - runner_group_id: ephemeralRunnerConfig.runnerGroupId, - labels: ephemeralRunnerConfig.runnerLabels, - }) - : await ghClient.actions.generateRunnerJitconfigForRepo({ - owner: githubRunnerConfig.runnerOwner.split('/')[0], - repo: githubRunnerConfig.runnerOwner.split('/')[1], - name: ephemeralRunnerConfig.runnerName, - runner_group_id: ephemeralRunnerConfig.runnerGroupId, - labels: ephemeralRunnerConfig.runnerLabels, - }); - - metricGitHubAppRateLimit(runnerConfig.headers); - - // tag the EC2 instance with GitHub runner metadata - await tagRunnerMetadata(instance, runnerConfig.data.runner.id.toString(), runnerLabels); - - // store jit config in ssm parameter store - logger.debug('Runner JIT config for ephemeral runner generated.', { - instance: instance, - }); - await putParameter(`${githubRunnerConfig.ssmTokenPath}/${instance}`, runnerConfig.data.encoded_jit_config, true, { - tags: [{ Key: 'InstanceId', Value: instance }, ...githubRunnerConfig.ssmParameterStoreTags], - }); - if (isDelay) { - // Delay to prevent AWS ssm rate limits by being within the max throughput limit - await delay(25); - } - } catch (error) { - failedInstances.push(instance); - logger.warn('Failed to create JIT config for instance, continuing with remaining instances', { - instance: instance, - error: error instanceof Error ? error.message : String(error), - }); - } - } - - if (failedInstances.length > 0) { - logger.error('Failed to create JIT config for some instances', { - failedInstances: failedInstances, - totalInstances: instances.length, - successfulInstances: instances.length - failedInstances.length, - }); - } - - return failedInstances; -} - -/** - * Parses EC2 override configuration from GitHub labels. - * - * Supported label formats: - * - * Basic Fleet Overrides: - * - ghr-ec2-instance-type: - Set specific instance type (e.g., c5.xlarge) - * - ghr-ec2-max-price: - Set maximum spot price - * - ghr-ec2-subnet-id: - Set subnet ID - * - ghr-ec2-availability-zone: - Set availability zone - * - ghr-ec2-availability-zone-id: - Set availability zone ID - * - ghr-ec2-weighted-capacity: - Set weighted capacity - * - ghr-ec2-priority: - Set launch priority - * - ghr-ec2-image-id: - Override AMI ID - * - * Instance Requirements (vCPU & Memory): - * - ghr-ec2-vcpu-count-min: - Set minimum vCPU count - * - ghr-ec2-vcpu-count-max: - Set maximum vCPU count - * - ghr-ec2-memory-mib-min: - Set minimum memory in MiB - * - ghr-ec2-memory-mib-max: - Set maximum memory in MiB - * - ghr-ec2-memory-gib-per-vcpu-min: - Set min memory per vCPU ratio - * - ghr-ec2-memory-gib-per-vcpu-max: - Set max memory per vCPU ratio - * - * Instance Requirements (CPU & Performance): - * - ghr-ec2-cpu-manufacturers: - CPU manufacturers (semicolon-separated: intel;amd;amazon-web-services) - * - ghr-ec2-instance-generations: - Instance generations (semicolon-separated: current;previous) - * - ghr-ec2-excluded-instance-types: - Exclude instance types (semicolon-separated) - * - ghr-ec2-allowed-instance-types: - Allow only specific instance types (semicolon-separated) - * - ghr-ec2-burstable-performance: - Burstable performance (included,excluded,required) - * - ghr-ec2-bare-metal: - Bare metal (included,excluded,required) - * - * Instance Requirements (Accelerators/GPU): - * - ghr-ec2-accelerator-types: - Accelerator types (semicolon-separated: gpu;fpga;inference) - * - ghr-ec2-accelerator-count-min: - Set minimum accelerator count - * - ghr-ec2-accelerator-count-max: - Set maximum accelerator count - * - ghr-ec2-accelerator-manufacturers: - Accelerator manufacturers (semicolon-separated: nvidia;amd;amazon-web-services;xilinx) - * - ghr-ec2-accelerator-names: - Specific accelerator names (semicolon-separated) - * - ghr-ec2-accelerator-total-memory-mib-min: - Min accelerator total memory in MiB - * - ghr-ec2-accelerator-total-memory-mib-max: - Max accelerator total memory in MiB - * - * Instance Requirements (Network & Storage): - * - ghr-ec2-network-interface-count-min: - Min network interfaces - * - ghr-ec2-network-interface-count-max: - Max network interfaces - * - ghr-ec2-network-bandwidth-gbps-min: - Min network bandwidth in Gbps - * - ghr-ec2-network-bandwidth-gbps-max: - Max network bandwidth in Gbps - * - ghr-ec2-local-storage: - Local storage (included,excluded,required) - * - ghr-ec2-local-storage-types: - Local storage types (semicolon-separated: hdd;ssd) - * - ghr-ec2-total-local-storage-gb-min: - Min total local storage in GB - * - ghr-ec2-total-local-storage-gb-max: - Max total local storage in GB - * - ghr-ec2-baseline-ebs-bandwidth-mbps-min: - Min baseline EBS bandwidth in Mbps - * - ghr-ec2-baseline-ebs-bandwidth-mbps-max: - Max baseline EBS bandwidth in Mbps - * - * Placement: - * - ghr-ec2-placement-group-name: - Placement group name - * - ghr-ec2-placement-group-id: - Placement group ID - * - ghr-ec2-placement-tenancy: - Tenancy (default,dedicated,host) - * - ghr-ec2-placement-host-id: - Dedicated host ID - * - ghr-ec2-placement-affinity: - Affinity (default,host) - * - ghr-ec2-placement-partition-number: - Partition number - * - ghr-ec2-placement-availability-zone: - Placement availability zone - * - ghr-ec2-placement-availability-zone-id: - Placement availability zone ID - * - ghr-ec2-placement-spread-domain: - Spread domain - * - ghr-ec2-placement-host-resource-group-arn: - Host resource group ARN - * - * Block Device Mappings: - * - ghr-ec2-block-device-name: - Block device name - * - ghr-ec2-ebs-volume-size: - EBS volume size in GB - * - ghr-ec2-ebs-volume-type: - EBS volume type (gp2,gp3,io1,io2,st1,sc1) - * - ghr-ec2-ebs-iops: - EBS IOPS - * - ghr-ec2-ebs-throughput: - EBS throughput in MB/s (gp3 only) - * - ghr-ec2-ebs-encrypted: - EBS encryption (true,false) - * - ghr-ec2-ebs-kms-key-id: - KMS key ID for encryption - * - ghr-ec2-ebs-delete-on-termination: - Delete on termination (true,false) - * - ghr-ec2-ebs-snapshot-id: - Snapshot ID for EBS volume - * - ghr-ec2-block-device-virtual-name: - Virtual device name (ephemeral storage) - * - ghr-ec2-block-device-no-device: - Suppresses device mapping - * - * Pricing & Advanced: - * - ghr-ec2-spot-max-price-percentage-over-lowest-price: - Spot max price as % over lowest price - * - ghr-ec2-on-demand-max-price-percentage-over-lowest-price: - On-demand max price as % over lowest price - * - ghr-ec2-max-spot-price-as-percentage-of-optimal-on-demand-price: - Max spot price as % of optimal on-demand - * - ghr-ec2-require-hibernate-support: - Require hibernate support (true,false) - * - ghr-ec2-require-encryption-in-transit: - Require encryption in-transit (true,false) - * - ghr-ec2-baseline-performance-factors-cpu-reference-families: - CPU baseline performance reference families (semicolon-separated) - * - * Example: - * runs-on: [self-hosted, linux, ghr-ec2-vcpu-count-min:4, ghr-ec2-memory-mib-min:16384, ghr-ec2-accelerator-types:gpu] - * - * @param labels - Array of GitHub workflow job labels - * @param defaultBlockDeviceName - Device name to use when dynamic block device labels create a mapping - * @returns EC2 override configuration object or undefined if no valid config found - */ -export function parseEc2OverrideConfig( - labels: string[], - defaultBlockDeviceName?: string, -): Ec2OverrideConfig | undefined { - const ec2Labels = labels.filter((l) => l.startsWith('ghr-ec2-')); - const config: Ec2OverrideConfig = {}; - - for (const label of ec2Labels) { - const [key, ...valueParts] = label.replace('ghr-ec2-', '').split(':'); - const value = valueParts.join(':'); - - if (!value) continue; - - // Basic Fleet Overrides - if (key === 'instance-type') { - config.InstanceType = value as _InstanceType; - } else if (key === 'subnet-id') { - config.SubnetId = value; - } else if (key === 'availability-zone') { - config.AvailabilityZone = value; - } else if (key === 'availability-zone-id') { - config.AvailabilityZoneId = value; - } else if (key === 'max-price') { - config.MaxPrice = value; - } else if (key === 'priority') { - config.Priority = parseFloat(value); - } else if (key === 'weighted-capacity') { - config.WeightedCapacity = parseFloat(value); - } else if (key === 'image-id') { - config.ImageId = value; - } - - // Placement - else if (key.startsWith('placement-')) { - config.Placement = config.Placement || ({} as Placement); - const placementKey = key.replace('placement-', ''); - if (placementKey === 'availability-zone-id') { - config.Placement.AvailabilityZoneId = value; - } else if (placementKey === 'affinity') { - config.Placement.Affinity = value; - } else if (placementKey === 'group-name') { - config.Placement.GroupName = value; - } else if (placementKey === 'partition-number') { - config.Placement.PartitionNumber = parseInt(value, 10); - } else if (placementKey === 'host-id') { - config.Placement.HostId = value; - } else if (placementKey === 'tenancy') { - config.Placement.Tenancy = value as Tenancy; - } else if (placementKey === 'spread-domain') { - config.Placement.SpreadDomain = value; - } else if (placementKey === 'host-resource-group-arn') { - config.Placement.HostResourceGroupArn = value; - } else if (placementKey === 'group-id') { - config.Placement.GroupId = value; - } else if (placementKey === 'availability-zone') { - config.Placement.AvailabilityZone = value; - } - } - - // Block Device Mappings - else if (key === 'block-device-name') { - getOrCreateBlockDeviceMapping(config, defaultBlockDeviceName).DeviceName = value; - } else if (key === 'block-device-virtual-name') { - getOrCreateBlockDeviceMapping(config, defaultBlockDeviceName).VirtualName = value; - } else if (key.startsWith('ebs-')) { - const blockDeviceMapping = getOrCreateBlockDeviceMapping(config, defaultBlockDeviceName); - const ebsKey = key.replace('ebs-', ''); - const ebs = blockDeviceMapping.Ebs || (blockDeviceMapping.Ebs = {} as FleetEbsBlockDeviceRequest); - - if (ebsKey === 'encrypted') { - ebs.Encrypted = value.toLowerCase() === 'true'; - } else if (ebsKey === 'delete-on-termination') { - ebs.DeleteOnTermination = value.toLowerCase() === 'true'; - } else if (ebsKey === 'iops') { - ebs.Iops = parseInt(value, 10); - } else if (ebsKey === 'throughput') { - ebs.Throughput = parseInt(value, 10); - } else if (ebsKey === 'kms-key-id') { - ebs.KmsKeyId = value; - } else if (ebsKey === 'snapshot-id') { - ebs.SnapshotId = value; - } else if (ebsKey === 'volume-size') { - ebs.VolumeSize = parseInt(value, 10); - } else if (ebsKey === 'volume-type') { - ebs.VolumeType = value as VolumeType; - } - } else if (key === 'block-device-no-device') { - getOrCreateBlockDeviceMapping(config, defaultBlockDeviceName).NoDevice = value; - } - - // Instance Requirements - else if (key.startsWith('vcpu-count-')) { - config.InstanceRequirements = config.InstanceRequirements || ({} as InstanceRequirementsRequest); - config.InstanceRequirements.VCpuCount = config.InstanceRequirements.VCpuCount || ({} as VCpuCountRangeRequest); - const subKey = key.replace('vcpu-count-', ''); - config.InstanceRequirements.VCpuCount![subKey === 'min' ? 'Min' : 'Max'] = parseInt(value, 10); - } else if (key.startsWith('memory-mib-')) { - config.InstanceRequirements = config.InstanceRequirements || ({} as InstanceRequirementsRequest); - config.InstanceRequirements.MemoryMiB = config.InstanceRequirements.MemoryMiB || ({} as MemoryMiBRequest); - const subKey = key.replace('memory-mib-', ''); - config.InstanceRequirements.MemoryMiB![subKey === 'min' ? 'Min' : 'Max'] = parseInt(value, 10); - } else if (key === 'cpu-manufacturers') { - config.InstanceRequirements = config.InstanceRequirements || ({} as InstanceRequirementsRequest); - config.InstanceRequirements.CpuManufacturers = splitEc2OverrideListValue(value) as CpuManufacturer[]; - } else if (key.startsWith('memory-gib-per-vcpu-')) { - config.InstanceRequirements = config.InstanceRequirements || ({} as InstanceRequirementsRequest); - config.InstanceRequirements.MemoryGiBPerVCpu = - config.InstanceRequirements.MemoryGiBPerVCpu || ({} as MemoryGiBPerVCpuRequest); - const subKey = key.replace('memory-gib-per-vcpu-', ''); - config.InstanceRequirements.MemoryGiBPerVCpu![subKey === 'min' ? 'Min' : 'Max'] = parseFloat(value); - } else if (key === 'excluded-instance-types') { - config.InstanceRequirements = config.InstanceRequirements || ({} as InstanceRequirementsRequest); - config.InstanceRequirements.ExcludedInstanceTypes = splitEc2OverrideListValue(value); - } else if (key === 'instance-generations') { - config.InstanceRequirements = config.InstanceRequirements || ({} as InstanceRequirementsRequest); - config.InstanceRequirements.InstanceGenerations = splitEc2OverrideListValue(value) as InstanceGeneration[]; - } else if (key === 'spot-max-price-percentage-over-lowest-price') { - config.InstanceRequirements = config.InstanceRequirements || ({} as InstanceRequirementsRequest); - config.InstanceRequirements.SpotMaxPricePercentageOverLowestPrice = parseInt(value, 10); - } else if (key === 'on-demand-max-price-percentage-over-lowest-price') { - config.InstanceRequirements = config.InstanceRequirements || ({} as InstanceRequirementsRequest); - config.InstanceRequirements.OnDemandMaxPricePercentageOverLowestPrice = parseInt(value, 10); - } else if (key === 'bare-metal') { - config.InstanceRequirements = config.InstanceRequirements || ({} as InstanceRequirementsRequest); - config.InstanceRequirements.BareMetal = value as BareMetal; - } else if (key === 'burstable-performance') { - config.InstanceRequirements = config.InstanceRequirements || ({} as InstanceRequirementsRequest); - config.InstanceRequirements.BurstablePerformance = value as BurstablePerformance; - } else if (key === 'require-hibernate-support') { - config.InstanceRequirements = config.InstanceRequirements || ({} as InstanceRequirementsRequest); - config.InstanceRequirements.RequireHibernateSupport = value.toLowerCase() === 'true'; - } else if (key.startsWith('network-interface-count-')) { - config.InstanceRequirements = config.InstanceRequirements || ({} as InstanceRequirementsRequest); - config.InstanceRequirements.NetworkInterfaceCount = - config.InstanceRequirements.NetworkInterfaceCount || ({} as NetworkInterfaceCountRequest); - const subKey = key.replace('network-interface-count-', ''); - config.InstanceRequirements.NetworkInterfaceCount![subKey === 'min' ? 'Min' : 'Max'] = parseInt(value, 10); - } else if (key === 'local-storage') { - config.InstanceRequirements = config.InstanceRequirements || ({} as InstanceRequirementsRequest); - config.InstanceRequirements.LocalStorage = value as LocalStorage; - } else if (key === 'local-storage-types') { - config.InstanceRequirements = config.InstanceRequirements || ({} as InstanceRequirementsRequest); - config.InstanceRequirements.LocalStorageTypes = splitEc2OverrideListValue(value) as LocalStorageType[]; - } else if (key.startsWith('total-local-storage-gb-')) { - config.InstanceRequirements = config.InstanceRequirements || ({} as InstanceRequirementsRequest); - config.InstanceRequirements.TotalLocalStorageGB = - config.InstanceRequirements.TotalLocalStorageGB || ({} as TotalLocalStorageGBRequest); - const subKey = key.replace('total-local-storage-gb-', ''); - config.InstanceRequirements.TotalLocalStorageGB![subKey === 'min' ? 'Min' : 'Max'] = parseFloat(value); - } else if (key.startsWith('baseline-ebs-bandwidth-mbps-')) { - config.InstanceRequirements = config.InstanceRequirements || ({} as InstanceRequirementsRequest); - config.InstanceRequirements.BaselineEbsBandwidthMbps = - config.InstanceRequirements.BaselineEbsBandwidthMbps || ({} as BaselineEbsBandwidthMbpsRequest); - const subKey = key.replace('baseline-ebs-bandwidth-mbps-', ''); - config.InstanceRequirements.BaselineEbsBandwidthMbps![subKey === 'min' ? 'Min' : 'Max'] = parseInt(value, 10); - } else if (key === 'accelerator-types') { - config.InstanceRequirements = config.InstanceRequirements || ({} as InstanceRequirementsRequest); - config.InstanceRequirements.AcceleratorTypes = splitEc2OverrideListValue(value) as AcceleratorType[]; - } else if (key.startsWith('accelerator-count-')) { - config.InstanceRequirements = config.InstanceRequirements || ({} as InstanceRequirementsRequest); - config.InstanceRequirements.AcceleratorCount = - config.InstanceRequirements.AcceleratorCount || ({} as AcceleratorCountRequest); - const subKey = key.replace('accelerator-count-', ''); - config.InstanceRequirements.AcceleratorCount![subKey === 'min' ? 'Min' : 'Max'] = parseInt(value, 10); - } else if (key === 'accelerator-manufacturers') { - config.InstanceRequirements = config.InstanceRequirements || ({} as InstanceRequirementsRequest); - config.InstanceRequirements.AcceleratorManufacturers = splitEc2OverrideListValue( - value, - ) as AcceleratorManufacturer[]; - } else if (key === 'accelerator-names') { - config.InstanceRequirements = config.InstanceRequirements || ({} as InstanceRequirementsRequest); - config.InstanceRequirements.AcceleratorNames = splitEc2OverrideListValue(value) as AcceleratorName[]; - } else if (key.startsWith('accelerator-total-memory-mib-')) { - config.InstanceRequirements = config.InstanceRequirements || ({} as InstanceRequirementsRequest); - config.InstanceRequirements.AcceleratorTotalMemoryMiB = - config.InstanceRequirements.AcceleratorTotalMemoryMiB || ({} as AcceleratorTotalMemoryMiBRequest); - const subKey = key.replace('accelerator-total-memory-mib-', ''); - config.InstanceRequirements.AcceleratorTotalMemoryMiB![subKey === 'min' ? 'Min' : 'Max'] = parseInt(value, 10); - } else if (key.startsWith('network-bandwidth-gbps-')) { - config.InstanceRequirements = config.InstanceRequirements || ({} as InstanceRequirementsRequest); - config.InstanceRequirements.NetworkBandwidthGbps = - config.InstanceRequirements.NetworkBandwidthGbps || ({} as NetworkBandwidthGbpsRequest); - const subKey = key.replace('network-bandwidth-gbps-', ''); - config.InstanceRequirements.NetworkBandwidthGbps![subKey === 'min' ? 'Min' : 'Max'] = parseFloat(value); - } else if (key === 'allowed-instance-types') { - config.InstanceRequirements = config.InstanceRequirements || ({} as InstanceRequirementsRequest); - config.InstanceRequirements.AllowedInstanceTypes = splitEc2OverrideListValue(value); - } else if (key === 'max-spot-price-as-percentage-of-optimal-on-demand-price') { - config.InstanceRequirements = config.InstanceRequirements || ({} as InstanceRequirementsRequest); - config.InstanceRequirements.MaxSpotPriceAsPercentageOfOptimalOnDemandPrice = parseInt(value, 10); - } else if (key === 'baseline-performance-factors-cpu-reference-families') { - config.InstanceRequirements = config.InstanceRequirements || ({} as InstanceRequirementsRequest); - config.InstanceRequirements.BaselinePerformanceFactors = - config.InstanceRequirements.BaselinePerformanceFactors || ({} as BaselinePerformanceFactorsRequest); - config.InstanceRequirements.BaselinePerformanceFactors.Cpu = - config.InstanceRequirements.BaselinePerformanceFactors.Cpu || ({} as CpuPerformanceFactorRequest); - config.InstanceRequirements.BaselinePerformanceFactors.Cpu.References = splitEc2OverrideListValue(value).map( - (family) => ({ InstanceFamily: family }), - ) as PerformanceFactorReferenceRequest[]; - } else if (key === 'require-encryption-in-transit') { - config.InstanceRequirements = config.InstanceRequirements || ({} as InstanceRequirementsRequest); - config.InstanceRequirements.RequireEncryptionInTransit = value.toLowerCase() === 'true'; - } - } - - return Object.keys(config).length > 0 ? config : undefined; -} - -function splitEc2OverrideListValue(value: string): string[] { - return value.split(EC2_OVERRIDE_LIST_VALUE_SEPARATOR); -} - -function getOrCreateBlockDeviceMapping( - config: Ec2OverrideConfig, - defaultBlockDeviceName?: string, -): FleetBlockDeviceMappingRequest { - config.BlockDeviceMappings = - config.BlockDeviceMappings || - ([defaultBlockDeviceName ? { DeviceName: defaultBlockDeviceName } : {}] as FleetBlockDeviceMappingRequest[]); - return config.BlockDeviceMappings[0]; -} - -function shouldLoadLaunchTemplateBlockDeviceName(labels: string[]): boolean { - const blockDeviceNameLabel = 'ghr-ec2-block-device-name:'; - let hasBlockDeviceOverride = false; - let hasBlockDeviceName = false; - - for (const label of labels) { - hasBlockDeviceOverride = - hasBlockDeviceOverride || label.startsWith('ghr-ec2-ebs-') || label.startsWith('ghr-ec2-block-device-'); - - hasBlockDeviceName = - hasBlockDeviceName || (label.startsWith(blockDeviceNameLabel) && label.slice(blockDeviceNameLabel.length) !== ''); - } - - return hasBlockDeviceOverride && !hasBlockDeviceName; -} - -async function getDefaultBlockDeviceNameFromLaunchTemplate(launchTemplateName: string): Promise { - const ec2Client = getTracedAWSV3Client(new EC2Client({ region: process.env.AWS_REGION })); - const launchTemplateVersions = await ec2Client.send( - new DescribeLaunchTemplateVersionsCommand({ - LaunchTemplateName: launchTemplateName, - Versions: ['$Default'], - }), - ); - const blockDeviceMappings = - launchTemplateVersions.LaunchTemplateVersions?.[0]?.LaunchTemplateData?.BlockDeviceMappings; - const blockDeviceName = - blockDeviceMappings?.find((blockDeviceMapping) => blockDeviceMapping.DeviceName && blockDeviceMapping.Ebs) - ?.DeviceName ?? blockDeviceMappings?.find((blockDeviceMapping) => blockDeviceMapping.DeviceName)?.DeviceName; - - if (!blockDeviceName) { - throw new Error(`Failed to determine block device name from launch template '${launchTemplateName}'.`); - } - - return blockDeviceName; -} - function labelsHash(labels: string[]): string { const prefix = 'ghr-'; diff --git a/lambdas/functions/control-plane/src/scale-runners/types.ts b/lambdas/functions/control-plane/src/scale-runners/types.ts new file mode 100644 index 0000000000..f3cb699b48 --- /dev/null +++ b/lambdas/functions/control-plane/src/scale-runners/types.ts @@ -0,0 +1,47 @@ +export type LambdaRunnerSource = 'scale-up-lambda' | 'pool-lambda'; +export type GitHubRunnerType = 'Org' | 'Repo'; + +export interface RunnerGroup { + name: string; + id: number; +} + +export interface ActionRequestMessage { + id: number; + eventType: 'check_run' | 'workflow_job'; + repositoryName: string; + repositoryOwner: string; + installationId: number; + repoOwnerType: string; + retryCounter?: number; + labels?: string[]; +} + +export interface ActionRequestMessageSQS extends ActionRequestMessage { + messageId: string; +} + +export interface ActionRequestMessageRetry extends ActionRequestMessage { + retryCounter: number; +} + +export interface CreateGitHubRunnerConfig { + ephemeral: boolean; + ghesBaseUrl?: string; + enableJitConfig: boolean; + runnerLabels: string; + runnerGroup: string; + runnerNamePrefix: string; + runnerOwner: string; + runnerType: GitHubRunnerType; + disableAutoUpdate: boolean; + ssmTokenPath: string; + ssmConfigPath: string; + ssmParameterStoreTags: { Key: string; Value: string }[]; +} + +export interface EphemeralRunnerConfig { + runnerName: string; + runnerGroupId: number; + runnerLabels: string[]; +} diff --git a/lambdas/functions/webhook/package.json b/lambdas/functions/webhook/package.json index 6e30328b35..767b97532b 100644 --- a/lambdas/functions/webhook/package.json +++ b/lambdas/functions/webhook/package.json @@ -30,6 +30,7 @@ "dependencies": { "@aws-github-runner/aws-powertools-util": "*", "@aws-github-runner/aws-ssm-util": "*", + "@aws-github-runner/runner-provider": "*", "@aws-sdk/client-sqs": "^3.1009.0", "@middy/core": "^6.4.5", "@octokit/rest": "22.0.1", diff --git a/lambdas/functions/webhook/src/runners/aws-dynamic-labels-policy.ts b/lambdas/functions/webhook/src/runners/aws-dynamic-labels-policy.ts new file mode 100644 index 0000000000..61625bfb00 --- /dev/null +++ b/lambdas/functions/webhook/src/runners/aws-dynamic-labels-policy.ts @@ -0,0 +1,15 @@ +export interface AwsDynamicLabelsValueRule { + allowed?: string[]; + denied?: string[]; + max?: number | string; +} + +/** + * AWS dynamic labels policy schema. `blocked_keys` rejects keys outright; + * `restricted_keys` applies optional per-key value rules. Provider-specific + * evaluators decide which dynamic label prefixes the policy applies to. + */ +export interface AwsDynamicLabelsPolicy { + blocked_keys?: string[]; + restricted_keys?: Record; +} diff --git a/lambdas/functions/webhook/src/runners/aws-dynamic-labels-provider.ts b/lambdas/functions/webhook/src/runners/aws-dynamic-labels-provider.ts new file mode 100644 index 0000000000..bd2d8faa7a --- /dev/null +++ b/lambdas/functions/webhook/src/runners/aws-dynamic-labels-provider.ts @@ -0,0 +1,19 @@ +import type { RunnerProviderType } from '@aws-github-runner/runner-provider'; + +import type { RunnerMatcherConfig } from '../sqs'; + +export interface AwsDynamicLabelDispatchTarget { + queue: RunnerMatcherConfig; + labels: string[]; +} + +export interface SelectAwsDynamicLabelQueueInput { + queue: RunnerMatcherConfig; + nonGhrLabels: string[]; + sanitizedGhrLabels: string[]; +} + +export interface AwsDynamicLabelProviderStrategy { + type: RunnerProviderType; + selectQueue(input: SelectAwsDynamicLabelQueueInput): AwsDynamicLabelDispatchTarget | undefined; +} diff --git a/lambdas/functions/webhook/src/runners/aws-dynamic-labels.ts b/lambdas/functions/webhook/src/runners/aws-dynamic-labels.ts new file mode 100644 index 0000000000..ccfb2e7462 --- /dev/null +++ b/lambdas/functions/webhook/src/runners/aws-dynamic-labels.ts @@ -0,0 +1,33 @@ +import { createChildLogger } from '@aws-github-runner/aws-powertools-util'; +import { normalizeRunnerProviderType } from '@aws-github-runner/runner-provider'; + +import type { RunnerMatcherConfig } from '../sqs'; +import { AwsDynamicLabelDispatchTarget, AwsDynamicLabelProviderStrategy } from './aws-dynamic-labels-provider'; +import { ec2DynamicLabelProviderStrategy } from './ec2-dynamic-labels'; + +const logger = createChildLogger('handler'); + +const awsDynamicLabelProviderStrategies: AwsDynamicLabelProviderStrategy[] = [ec2DynamicLabelProviderStrategy]; + +export function selectAwsDynamicLabelQueue( + matches: RunnerMatcherConfig[], + nonGhrLabels: string[], + sanitizedGhrLabels: string[], +): AwsDynamicLabelDispatchTarget | undefined { + for (const queue of matches) { + const provider = normalizeRunnerProviderType(queue.runnerProvider); + const strategy = provider + ? awsDynamicLabelProviderStrategies.find((strategy) => strategy.type === provider) + : undefined; + + if (!strategy) { + logger.warn(`Queue ${queue.id} has unsupported runner provider '${provider ?? String(queue.runnerProvider)}'`); + continue; + } + + const target = strategy.selectQueue({ queue, nonGhrLabels, sanitizedGhrLabels }); + if (target) return target; + } + + return undefined; +} diff --git a/lambdas/functions/webhook/src/runners/dispatch.test.ts b/lambdas/functions/webhook/src/runners/dispatch.test.ts index b776dcc865..c2b9116680 100644 --- a/lambdas/functions/webhook/src/runners/dispatch.test.ts +++ b/lambdas/functions/webhook/src/runners/dispatch.test.ts @@ -1,4 +1,5 @@ import { getParameter } from '@aws-github-runner/aws-ssm-util'; +import type { RunnerProviderType } from '@aws-github-runner/runner-provider'; import nock from 'nock'; import { WorkflowJobEvent } from '@octokit/webhooks-types'; @@ -7,7 +8,10 @@ import workFlowJobEvent from '../../test/resources/github_workflowjob_event.json import runnerConfig from '../../test/resources/multi_runner_configurations.json'; import { RunnerConfig, sendActionRequest } from '../sqs'; -import { canRunJob, dispatch } from './dispatch'; +import type { RunnerMatcherConfig } from '../sqs'; +import { dispatch } from './dispatch'; +import { selectAwsDynamicLabelQueue } from './aws-dynamic-labels'; +import { canRunJob } from './labels'; import { ConfigDispatcher } from '../ConfigLoader'; import { logger } from '@aws-github-runner/aws-powertools-util'; import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; @@ -456,7 +460,7 @@ describe('Dispatcher', () => { labelMatchers: [['self-hosted', 'linux']], exactMatch: true, enableDynamicLabels: true, - ec2DynamicLabelsPolicy: { + awsDynamicLabelsPolicy: { restricted_keys: { 'instance-type': { allowed: ['m5.*'] }, }, @@ -499,7 +503,7 @@ describe('Dispatcher', () => { labelMatchers: [['self-hosted', 'linux']], exactMatch: true, enableDynamicLabels: true, - ec2DynamicLabelsPolicy: { + awsDynamicLabelsPolicy: { restricted_keys: { 'instance-type': { allowed: ['m5.*'] }, }, @@ -537,7 +541,7 @@ describe('Dispatcher', () => { labelMatchers: [['self-hosted', 'linux']], exactMatch: true, enableDynamicLabels: true, - ec2DynamicLabelsPolicy: {}, + awsDynamicLabelsPolicy: {}, }, }, ]); @@ -577,3 +581,87 @@ async function createConfig(repositoryAllowList?: string[], runnerConfig?: Runne mockSSMResponse(runnerConfig); return await ConfigDispatcher.load(); } + +describe('selectAwsDynamicLabelQueue', () => { + it('defaults queues without a provider to EC2 dynamic label handling', () => { + const queue = runnerQueue('default-ec2'); + + expect(selectAwsDynamicLabelQueue([queue], ['self-hosted', 'linux'], ['ghr-ec2-instance-type:t3.large'])).toEqual({ + queue, + labels: ['self-hosted', 'linux', 'ghr-ec2-instance-type:t3.large'], + }); + }); + + it('normalizes runner provider casing and surrounding whitespace', () => { + const queue = runnerQueue('normalized-ec2'); + (queue as unknown as { runnerProvider: string }).runnerProvider = ' EC2 '; + + expect(selectAwsDynamicLabelQueue([queue], ['self-hosted', 'linux'], ['ghr-ec2-instance-type:t3.large'])).toEqual({ + queue, + labels: ['self-hosted', 'linux', 'ghr-ec2-instance-type:t3.large'], + }); + }); + + it('skips an unsupported provider strategy and selects the next supported queue', () => { + const unsupportedQueue = runnerQueue('unsupported-provider'); + (unsupportedQueue as unknown as { runnerProvider: string }).runnerProvider = 'unsupported'; + const ec2Queue = runnerQueue('ec2'); + + expect( + selectAwsDynamicLabelQueue( + [unsupportedQueue, ec2Queue], + ['self-hosted', 'linux'], + ['ghr-ec2-instance-type:t3.large'], + ), + ).toEqual({ + queue: ec2Queue, + labels: ['self-hosted', 'linux', 'ghr-ec2-instance-type:t3.large'], + }); + }); + + it('rejects a malformed non-string runner provider without throwing', () => { + const queue = runnerQueue('malformed-provider'); + (queue as unknown as { runnerProvider: number }).runnerProvider = 42; + + expect( + selectAwsDynamicLabelQueue([queue], ['self-hosted', 'linux'], ['ghr-ec2-instance-type:t3.large']), + ).toBeUndefined(); + }); + + it('enforces a legacy EC2 dynamic labels policy when the new key is absent', () => { + const queue = runnerQueue('legacy-ec2-policy'); + queue.matcherConfig.ec2DynamicLabelsPolicy = { + blocked_keys: ['instance-type'], + }; + + expect( + selectAwsDynamicLabelQueue([queue], ['self-hosted', 'linux'], ['ghr-ec2-instance-type:t3.large']), + ).toBeUndefined(); + }); + + it('prefers the new AWS dynamic labels policy when both keys are present', () => { + const queue = runnerQueue('new-policy-precedence'); + queue.matcherConfig.ec2DynamicLabelsPolicy = { + blocked_keys: ['instance-type'], + }; + queue.matcherConfig.awsDynamicLabelsPolicy = null; + + expect(selectAwsDynamicLabelQueue([queue], ['self-hosted', 'linux'], ['ghr-ec2-instance-type:t3.large'])).toEqual({ + queue, + labels: ['self-hosted', 'linux', 'ghr-ec2-instance-type:t3.large'], + }); + }); +}); + +function runnerQueue(id: string, runnerProvider?: RunnerProviderType): RunnerMatcherConfig { + return { + id, + arn: `arn:${id}`, + runnerProvider, + matcherConfig: { + labelMatchers: [['self-hosted', 'linux']], + exactMatch: true, + enableDynamicLabels: true, + }, + }; +} diff --git a/lambdas/functions/webhook/src/runners/dispatch.ts b/lambdas/functions/webhook/src/runners/dispatch.ts index 16c65fb9a3..47c1f1bfc0 100644 --- a/lambdas/functions/webhook/src/runners/dispatch.ts +++ b/lambdas/functions/webhook/src/runners/dispatch.ts @@ -5,13 +5,11 @@ import { Response } from '../lambda'; import { RunnerMatcherConfig, sendActionRequest } from '../sqs'; import ValidationError from '../ValidationError'; import { ConfigDispatcher, ConfigWebhook, QueueSelectionStrategy } from '../ConfigLoader'; -import { violationsAgainstPolicy } from './dynamic-labels-policy'; +import { selectAwsDynamicLabelQueue } from './aws-dynamic-labels'; +import { canRunJob, splitWorkflowJobLabels } from './labels'; const logger = createChildLogger('handler'); -const GHR_LABEL_MAX_LENGTH = 128; -const GHR_LABEL_VALUE_PATTERN = /^[a-zA-Z0-9._/;\-:]+$/; - export async function dispatch( event: WorkflowJobEvent, eventType: string, @@ -55,11 +53,7 @@ async function handleWorkflowJob( return aStrict === bStrict ? 0 : aStrict ? -1 : 1; }); - const allLabels = body.workflow_job.labels; - const ghrLabels = allLabels.filter((l) => l.startsWith('ghr-')); - const sanitizedGhrLabels = sanitizeGhrLabels(ghrLabels); - const nonGhrLabels = allLabels.filter((l) => !l.startsWith('ghr-')); - const hasDynamicLabels = sanitizedGhrLabels.length > 0; + const { nonGhrLabels, sanitizedGhrLabels, hasDynamicLabels } = splitWorkflowJobLabels(body.workflow_job.labels); // 1. Collect all queues whose non-dynamic labels match the job. const matches: RunnerMatcherConfig[] = matcherConfig.filter((q) => @@ -87,29 +81,14 @@ async function handleWorkflowJob( targets = selectQueues(topMatches, queueSelectionStrategy); labelsToSend = nonGhrLabels; } else { - // Dynamic labels present: prefer the first match that has dynamic labels - // enabled AND accepts these labels under its policy. The queue selection - // strategy applies to standard jobs only; dynamic-label jobs always use the - // first compliant queue. - let compliant: RunnerMatcherConfig | undefined; - for (const q of matches) { - if (!q.matcherConfig.enableDynamicLabels) { - logger.warn(`Queue ${q.id} matches non-dynamic labels but does not allow dynamic labels; trying next match`); - continue; - } - const violations = violationsAgainstPolicy(sanitizedGhrLabels, q.matcherConfig.ec2DynamicLabelsPolicy); - if (violations.length === 0) { - compliant = q; - break; - } - for (const v of violations) { - logger.warn(`Queue ${q.id}: dynamic label '${v.label}' does not match policy (${v.reason}); trying next match`); - } - } - - if (compliant) { - targets = [compliant]; - labelsToSend = [...nonGhrLabels, ...sanitizedGhrLabels]; + // Dynamic labels present: prefer the first provider-compliant queue. The + // queue selection strategy applies to standard jobs only; dynamic-label jobs + // always use the first compliant queue. + const dynamicTarget = selectAwsDynamicLabelQueue(matches, nonGhrLabels, sanitizedGhrLabels); + + if (dynamicTarget) { + targets = [dynamicTarget.queue]; + labelsToSend = dynamicTarget.labels; } else { // No queue accepts the dynamic labels under its policy: refuse the job. logger.warn(`No queue accepts the dynamic labels for this job; not dispatching`, { @@ -152,7 +131,7 @@ async function handleWorkflowJob( * so a single pool's queue does not become a bottleneck. * - 'all' returns every candidate, scaling up one runner per matching pool and * letting the first to become available take the job (speed over cost). Note - * this multiplies instance launches and runner registrations per job. + * this multiplies AWS launches and runner registrations per job. */ function selectQueues(candidates: RunnerMatcherConfig[], strategy: QueueSelectionStrategy): RunnerMatcherConfig[] { switch (strategy) { @@ -174,50 +153,3 @@ function notAccepted(body: WorkflowJobEvent): Response { ); return { statusCode: 202, body: notAcceptedErrorMsg }; } - -function sanitizeGhrLabels(labels: string[]): string[] { - return labels.filter((label) => { - if (label.length > GHR_LABEL_MAX_LENGTH) { - logger.warn('Dynamic label exceeds max length, stripping', { label: label.substring(0, 40) }); - return false; - } - if (!GHR_LABEL_VALUE_PATTERN.test(label)) { - logger.warn('Dynamic label contains invalid characters, stripping', { label }); - return false; - } - return true; - }); -} - -/** - * Pure label match against a runner's `labelMatchers`. Caller is expected to - * pass only non-dynamic labels. - */ -export function canRunJob( - workflowJobLabels: string[], - runnerLabelsMatchers: string[][], - workflowLabelCheckAll: boolean, - bidirectionalLabelMatch = false, -): boolean { - const lowered = runnerLabelsMatchers.map((rl) => rl.map((l) => l.toLowerCase())); - - let match: boolean; - if (bidirectionalLabelMatch) { - const workflowLabelsLower = workflowJobLabels.map((wl) => wl.toLowerCase()); - match = lowered.some( - (rl) => workflowLabelsLower.every((wl) => rl.includes(wl)) && rl.every((r) => workflowLabelsLower.includes(r)), - ); - } else { - const matchLabels = workflowLabelCheckAll - ? lowered.some((rl) => workflowJobLabels.every((wl) => rl.includes(wl.toLowerCase()))) - : lowered.some((rl) => workflowJobLabels.some((wl) => rl.includes(wl.toLowerCase()))); - match = workflowJobLabels.length === 0 ? !matchLabels : matchLabels; - } - - logger.debug( - `Received workflow job event with labels: '${JSON.stringify(workflowJobLabels)}'. The event does ${ - match ? '' : 'NOT ' - }match the runner labels: '${Array.from(lowered).join(',')}'`, - ); - return match; -} diff --git a/lambdas/functions/webhook/src/runners/dynamic-labels-policy.test.ts b/lambdas/functions/webhook/src/runners/dynamic-labels-policy.test.ts index d9d3c9325d..0497bee33a 100644 --- a/lambdas/functions/webhook/src/runners/dynamic-labels-policy.test.ts +++ b/lambdas/functions/webhook/src/runners/dynamic-labels-policy.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest'; -import { violationsAgainstPolicy, type Ec2DynamicLabelsPolicy } from './dynamic-labels-policy'; +import { violationsAgainstPolicy, type Ec2DynamicLabelsPolicy } from './ec2-dynamic-labels-policy'; describe('violationsAgainstPolicy', () => { it('returns [] when policy is null/undefined', () => { diff --git a/lambdas/functions/webhook/src/runners/dynamic-labels-policy.ts b/lambdas/functions/webhook/src/runners/ec2-dynamic-labels-policy.ts similarity index 90% rename from lambdas/functions/webhook/src/runners/dynamic-labels-policy.ts rename to lambdas/functions/webhook/src/runners/ec2-dynamic-labels-policy.ts index 54003690b7..15b6f4235d 100644 --- a/lambdas/functions/webhook/src/runners/dynamic-labels-policy.ts +++ b/lambdas/functions/webhook/src/runners/ec2-dynamic-labels-policy.ts @@ -1,8 +1,6 @@ -export interface Ec2DynamicLabelsValueRule { - allowed?: string[]; - denied?: string[]; - max?: number | string; -} +import type { AwsDynamicLabelsPolicy, AwsDynamicLabelsValueRule } from './aws-dynamic-labels-policy'; + +export type Ec2DynamicLabelsValueRule = AwsDynamicLabelsValueRule; /** * EC2 dynamic labels policy schema. `blocked_keys` rejects keys outright; @@ -10,10 +8,7 @@ export interface Ec2DynamicLabelsValueRule { * `` segment of a `ghr-ec2-:` label in the same hyphenated * form as the labels themselves (e.g. `instance-type`). */ -export interface Ec2DynamicLabelsPolicy { - blocked_keys?: string[]; - restricted_keys?: Record; -} +export type Ec2DynamicLabelsPolicy = AwsDynamicLabelsPolicy; function globToRegExp(glob: string): RegExp { const escaped = glob.replace(/[.+^${}()|[\]\\]/g, '\\$&'); diff --git a/lambdas/functions/webhook/src/runners/ec2-dynamic-labels.ts b/lambdas/functions/webhook/src/runners/ec2-dynamic-labels.ts new file mode 100644 index 0000000000..44025ca8ad --- /dev/null +++ b/lambdas/functions/webhook/src/runners/ec2-dynamic-labels.ts @@ -0,0 +1,61 @@ +import { createChildLogger } from '@aws-github-runner/aws-powertools-util'; + +import { RunnerMatcherConfig } from '../sqs'; +import { AwsDynamicLabelDispatchTarget, AwsDynamicLabelProviderStrategy } from './aws-dynamic-labels-provider'; +import { violationsAgainstPolicy } from './ec2-dynamic-labels-policy'; + +const logger = createChildLogger('handler'); + +export type Ec2DynamicLabelDispatchTarget = AwsDynamicLabelDispatchTarget; + +function resolveEc2DynamicLabelsPolicy(queue: RunnerMatcherConfig) { + const hasAwsDynamicLabelsPolicy = Object.prototype.hasOwnProperty.call(queue.matcherConfig, 'awsDynamicLabelsPolicy'); + const hasLegacyEc2DynamicLabelsPolicy = Object.prototype.hasOwnProperty.call( + queue.matcherConfig, + 'ec2DynamicLabelsPolicy', + ); + + if (!hasAwsDynamicLabelsPolicy && hasLegacyEc2DynamicLabelsPolicy) { + logger.warn( + `Queue ${queue.id}: using deprecated matcherConfig.ec2DynamicLabelsPolicy; migrate to matcherConfig.awsDynamicLabelsPolicy`, + ); + return queue.matcherConfig.ec2DynamicLabelsPolicy; + } + + return queue.matcherConfig.awsDynamicLabelsPolicy; +} + +export function selectEc2DynamicLabelQueue( + matches: RunnerMatcherConfig[], + nonGhrLabels: string[], + sanitizedGhrLabels: string[], +): Ec2DynamicLabelDispatchTarget | undefined { + for (const queue of matches) { + if (!queue.matcherConfig.enableDynamicLabels) { + logger.warn(`Queue ${queue.id} matches non-dynamic labels but does not allow dynamic labels; trying next match`); + continue; + } + + const violations = violationsAgainstPolicy(sanitizedGhrLabels, resolveEc2DynamicLabelsPolicy(queue)); + if (violations.length === 0) { + return { + queue, + labels: [...nonGhrLabels, ...sanitizedGhrLabels], + }; + } + + for (const violation of violations) { + logger.warn( + `Queue ${queue.id}: dynamic label '${violation.label}' does not match policy (${violation.reason}); trying next match`, + ); + } + } + + return undefined; +} + +export const ec2DynamicLabelProviderStrategy: AwsDynamicLabelProviderStrategy = { + type: 'ec2', + selectQueue: ({ queue, nonGhrLabels, sanitizedGhrLabels }) => + selectEc2DynamicLabelQueue([queue], nonGhrLabels, sanitizedGhrLabels), +}; diff --git a/lambdas/functions/webhook/src/runners/labels.ts b/lambdas/functions/webhook/src/runners/labels.ts new file mode 100644 index 0000000000..d4094695aa --- /dev/null +++ b/lambdas/functions/webhook/src/runners/labels.ts @@ -0,0 +1,75 @@ +import { createChildLogger } from '@aws-github-runner/aws-powertools-util'; + +const logger = createChildLogger('handler'); + +const GHR_LABEL_MAX_LENGTH = 128; +const GHR_LABEL_VALUE_PATTERN = /^[a-zA-Z0-9._/;\-:]+$/; + +export interface WorkflowJobLabels { + allLabels: string[]; + ghrLabels: string[]; + sanitizedGhrLabels: string[]; + nonGhrLabels: string[]; + hasDynamicLabels: boolean; +} + +export function splitWorkflowJobLabels(labels: string[]): WorkflowJobLabels { + const ghrLabels = labels.filter((label) => label.startsWith('ghr-')); + const sanitizedGhrLabels = sanitizeGhrLabels(ghrLabels); + const nonGhrLabels = labels.filter((label) => !label.startsWith('ghr-')); + + return { + allLabels: labels, + ghrLabels, + sanitizedGhrLabels, + nonGhrLabels, + hasDynamicLabels: sanitizedGhrLabels.length > 0, + }; +} + +export function sanitizeGhrLabels(labels: string[]): string[] { + return labels.filter((label) => { + if (label.length > GHR_LABEL_MAX_LENGTH) { + logger.warn('Dynamic label exceeds max length, stripping', { label: label.substring(0, 40) }); + return false; + } + if (!GHR_LABEL_VALUE_PATTERN.test(label)) { + logger.warn('Dynamic label contains invalid characters, stripping', { label }); + return false; + } + return true; + }); +} + +/** + * Pure label match against a runner's `labelMatchers`. Caller is expected to + * pass only non-dynamic labels. + */ +export function canRunJob( + workflowJobLabels: string[], + runnerLabelsMatchers: string[][], + workflowLabelCheckAll: boolean, + bidirectionalLabelMatch = false, +): boolean { + const lowered = runnerLabelsMatchers.map((rl) => rl.map((l) => l.toLowerCase())); + + let match: boolean; + if (bidirectionalLabelMatch) { + const workflowLabelsLower = workflowJobLabels.map((wl) => wl.toLowerCase()); + match = lowered.some( + (rl) => workflowLabelsLower.every((wl) => rl.includes(wl)) && rl.every((r) => workflowLabelsLower.includes(r)), + ); + } else { + const matchLabels = workflowLabelCheckAll + ? lowered.some((rl) => workflowJobLabels.every((wl) => rl.includes(wl.toLowerCase()))) + : lowered.some((rl) => workflowJobLabels.some((wl) => rl.includes(wl.toLowerCase()))); + match = workflowJobLabels.length === 0 ? !matchLabels : matchLabels; + } + + logger.debug( + `Received workflow job event with labels: '${JSON.stringify(workflowJobLabels)}'. The event does ${ + match ? '' : 'NOT ' + }match the runner labels: '${Array.from(lowered).join(',')}'`, + ); + return match; +} diff --git a/lambdas/functions/webhook/src/sqs/index.ts b/lambdas/functions/webhook/src/sqs/index.ts index 460d197e65..ba805ce88c 100644 --- a/lambdas/functions/webhook/src/sqs/index.ts +++ b/lambdas/functions/webhook/src/sqs/index.ts @@ -1,8 +1,9 @@ import { SQS, SendMessageCommandInput } from '@aws-sdk/client-sqs'; import { WorkflowJobEvent } from '@octokit/webhooks-types'; import { createChildLogger, getTracedAWSV3Client } from '@aws-github-runner/aws-powertools-util'; +import type { RunnerProviderType } from '@aws-github-runner/runner-provider'; -import { Ec2DynamicLabelsPolicy } from '../runners/dynamic-labels-policy'; +import type { AwsDynamicLabelsPolicy } from '../runners/aws-dynamic-labels-policy'; const logger = createChildLogger('sqs'); @@ -24,13 +25,17 @@ export interface MatcherConfig { exactMatch: boolean; bidirectionalLabelMatch?: boolean; enableDynamicLabels?: boolean; - ec2DynamicLabelsPolicy?: Ec2DynamicLabelsPolicy | null; + awsDynamicLabelsPolicy?: AwsDynamicLabelsPolicy | null; + // TODO: Remove this legacy compatibility field and fallback in the next release. + /** @deprecated Use awsDynamicLabelsPolicy. Retained while existing SSM configurations migrate. */ + ec2DynamicLabelsPolicy?: AwsDynamicLabelsPolicy | null; } export type RunnerConfig = RunnerMatcherConfig[]; export interface RunnerMatcherConfig { matcherConfig: MatcherConfig; + runnerProvider?: RunnerProviderType; id: string; arn: string; } diff --git a/lambdas/libs/runner-provider/package.json b/lambdas/libs/runner-provider/package.json new file mode 100644 index 0000000000..6ccf3983d8 --- /dev/null +++ b/lambdas/libs/runner-provider/package.json @@ -0,0 +1,23 @@ +{ + "name": "@aws-github-runner/runner-provider", + "version": "1.0.0", + "main": "src/index.ts", + "type": "module", + "license": "MIT", + "scripts": { + "test": "NODE_ENV=test nx test", + "test:watch": "NODE_ENV=test nx test --watch", + "lint": "eslint src", + "format": "prettier --write \"**/*.ts\"", + "format-check": "prettier --check \"**/*.ts\"", + "all": "yarn format && yarn lint && yarn test" + }, + "nx": { + "includedScripts": [ + "format", + "format-check", + "lint", + "all" + ] + } +} diff --git a/lambdas/libs/runner-provider/src/index.test.ts b/lambdas/libs/runner-provider/src/index.test.ts new file mode 100644 index 0000000000..616ed40d37 --- /dev/null +++ b/lambdas/libs/runner-provider/src/index.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest'; + +import { normalizeRunnerProviderType, resolveRunnerProviderType } from './index'; + +describe('runner provider normalization', () => { + it.each([ + [undefined, 'ec2'], + ['', 'ec2'], + [' ', 'ec2'], + [' EC2 ', 'ec2'], + ])('normalizes provider type %j to %j', (type, expected) => { + expect(normalizeRunnerProviderType(type)).toBe(expected); + }); + + it.each([[' Unknown '], ['microvm'], [null], [1]])('returns undefined for unsupported provider type %j', (type) => { + expect(normalizeRunnerProviderType(type)).toBeUndefined(); + }); +}); + +describe('runner provider resolution', () => { + it.each([ + [undefined, 'ec2'], + ['', 'ec2'], + [' ', 'ec2'], + [' EC2 ', 'ec2'], + ])('resolves provider type %j to %j', (type, expected) => { + expect(resolveRunnerProviderType(type)).toBe(expected); + }); + + it.each([[' Unknown '], ['microvm'], [null], [1]])('rejects unsupported provider type %j', (type) => { + expect(() => resolveRunnerProviderType(type)).toThrow(`Unsupported runner provider type '${String(type)}'`); + }); +}); diff --git a/lambdas/libs/runner-provider/src/index.ts b/lambdas/libs/runner-provider/src/index.ts new file mode 100644 index 0000000000..bb73cfd983 --- /dev/null +++ b/lambdas/libs/runner-provider/src/index.ts @@ -0,0 +1,28 @@ +// TODO: Add MicroVM when its webhook and control-plane provider implementations are available. +const runnerProviderTypes = ['ec2'] as const; +export type RunnerProviderType = (typeof runnerProviderTypes)[number]; + +const defaultRunnerProvider: RunnerProviderType = 'ec2'; + +export function normalizeRunnerProviderType(type: unknown): RunnerProviderType | undefined { + if (type === undefined) return defaultRunnerProvider; + if (typeof type !== 'string') return undefined; + + const normalizedType = type.trim().toLowerCase(); + if (!normalizedType) return defaultRunnerProvider; + + return runnerProviderTypes.find((runnerProviderType) => runnerProviderType === normalizedType); +} + +export function resolveRunnerProviderType(type: unknown): RunnerProviderType { + const normalizedType = normalizeRunnerProviderType(type); + if (!normalizedType) { + throw new Error(`Unsupported runner provider type '${String(type)}'`); + } + + return normalizedType; +} + +export interface RunnerProvider { + type: RunnerProviderType; +} diff --git a/lambdas/libs/runner-provider/tsconfig.json b/lambdas/libs/runner-provider/tsconfig.json new file mode 100644 index 0000000000..9d616eca0f --- /dev/null +++ b/lambdas/libs/runner-provider/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.json", + "include": ["src/**/*"], + "exclude": ["src/**/*.test.ts"] +} diff --git a/lambdas/libs/runner-provider/vitest.config.ts b/lambdas/libs/runner-provider/vitest.config.ts new file mode 100644 index 0000000000..5179569242 --- /dev/null +++ b/lambdas/libs/runner-provider/vitest.config.ts @@ -0,0 +1,17 @@ +import { mergeConfig } from 'vitest/config'; +import defaultConfig from '../../vitest.base.config'; + +export default mergeConfig(defaultConfig, { + test: { + coverage: { + include: ['src/**/*.ts'], + exclude: ['src/**/*.test.ts', 'src/**/*.d.ts'], + thresholds: { + statements: 100, + branches: 100, + functions: 100, + lines: 100, + }, + }, + }, +}); diff --git a/lambdas/yarn.lock b/lambdas/yarn.lock index bfe69a07b9..df81fdf245 100644 --- a/lambdas/yarn.lock +++ b/lambdas/yarn.lock @@ -147,6 +147,7 @@ __metadata: dependencies: "@aws-github-runner/aws-powertools-util": "npm:*" "@aws-github-runner/aws-ssm-util": "npm:*" + "@aws-github-runner/runner-provider": "npm:*" "@aws-lambda-powertools/parameters": "npm:^2.31.0" "@aws-sdk/client-ec2": "npm:^3.1009.0" "@aws-sdk/client-sqs": "npm:^3.1009.0" @@ -192,6 +193,12 @@ __metadata: languageName: unknown linkType: soft +"@aws-github-runner/runner-provider@npm:*, @aws-github-runner/runner-provider@workspace:libs/runner-provider": + version: 0.0.0-use.local + resolution: "@aws-github-runner/runner-provider@workspace:libs/runner-provider" + languageName: unknown + linkType: soft + "@aws-github-runner/termination-watcher@workspace:functions/termination-watcher": version: 0.0.0-use.local resolution: "@aws-github-runner/termination-watcher@workspace:functions/termination-watcher" @@ -221,6 +228,7 @@ __metadata: dependencies: "@aws-github-runner/aws-powertools-util": "npm:*" "@aws-github-runner/aws-ssm-util": "npm:*" + "@aws-github-runner/runner-provider": "npm:*" "@aws-sdk/client-eventbridge": "npm:^3.1009.0" "@aws-sdk/client-sqs": "npm:^3.1009.0" "@middy/core": "npm:^6.4.5" diff --git a/main.tf b/main.tf index 546270fe2d..ca83523285 100644 --- a/main.tf +++ b/main.tf @@ -116,7 +116,7 @@ module "webhook" { exactMatch : var.enable_runner_workflow_job_labels_check_all bidirectionalLabelMatch : var.enable_runner_bidirectional_label_match enableDynamicLabels : var.enable_dynamic_labels - ec2DynamicLabelsPolicy : var.ec2_dynamic_labels_policy + awsDynamicLabelsPolicy : var.aws_dynamic_labels_policy } } } diff --git a/modules/multi-runner/README.md b/modules/multi-runner/README.md index 13085c2b3c..612df47652 100644 --- a/modules/multi-runner/README.md +++ b/modules/multi-runner/README.md @@ -152,7 +152,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, osx, 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, macOS Sequoia for osx, 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` disables 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."
lambda\_event\_source\_mapping\_batch\_size: "(Optional) Maximum number of records per Lambda invocation for this runner flavor. Overrides the module-level `lambda_event_source_mapping_batch_size` when set."
lambda\_event\_source\_mapping\_maximum\_batching\_window\_in\_seconds: "(Optional) Maximum seconds to gather records before invoking Lambda for this runner flavor. Overrides the module-level `lambda_event_source_mapping_maximum_batching_window_in_seconds` when set."
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."
license\_specifications: "Optional EC2 License Manager license configuration ARNs for the runner launch template. Required for macOS dedicated-host runners when the host resource group uses a Mac dedicated host license configuration."
use\_dedicated\_host: "Experimental! Can be removed / changed without trigger a major release. Whether to use EC2 dedicated hosts for the runners. Needed for macos runners Note that using dedicated hosts can increase cost significantly."
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`, `volume_initialization_rate`."
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)."
iam\_overrides: "Allows to (optionally) override the instance profile and runner role created by the module. Set `override_instance_profile` to true and provide the `instance_profile_name` to use an existing instance profile. Set `override_runner_role` to true and provide the `runner_role_arn` to use an existing role for the runner instances."
}
matcherConfig: {
labelMatchers: "The list of list of labels supported by the runner configuration. `[[self-hosted, linux, x64, example]]`"
exactMatch: "DEPRECATED: Use `bidirectionalLabelMatch` instead. 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. Note: this only checks that workflow labels are a subset of runner labels, not the reverse."
bidirectionalLabelMatch: "If set to true, the runner labels and workflow job labels must be an exact two-way match (same set, any order, no extras or missing labels). This is stricter than `exactMatch` which only checks that workflow labels are a subset of runner labels. 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."
enableDynamicLabels: "Experimental! When true the dispatcher allows `ghr-*` dynamic labels for jobs routed to this runner. Default false."
ec2DynamicLabelsPolicy: "Optional policy for `ghr-ec2-*` labels evaluated by the dispatcher. Only effective when `enableDynamicLabels = true`. Jobs whose EC2 dynamic labels violate every matching runner's policy are rejected with a 202 (a warning is logged). Evaluation: keys in `blocked_keys` are always rejected; keys in `restricted_keys` are allowed only when their value passes the rule; unlisted keys are allowed. Schema: `{ blocked_keys = [], restricted_keys = { = { allowed = [globs], denied = [globs], max = number|string } } }`. Keys use the dynamic label suffix, e.g. `instance-type` for `ghr-ec2-instance-type`."
}
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)
lambda_event_source_mapping_batch_size = optional(number, null)
lambda_event_source_mapping_maximum_batching_window_in_seconds = optional(number, null)
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 = optional(number)
threads_per_core = optional(number)
amd_sev_snp = optional(string)
nested_virtualization = optional(string)
}), 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)
license_specifications = optional(list(object({
license_configuration_arn = string
})), [])
use_dedicated_host = optional(bool, false)
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_initialization_rate = 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)
}), {})
iam_overrides = optional(object({
override_instance_profile = optional(bool, null)
instance_profile_name = optional(string, null)
override_runner_role = optional(bool, null)
runner_role_arn = optional(string, null)
}), {
override_instance_profile = false
instance_profile_name = null
override_runner_role = false
runner_role_arn = null
})
})
matcherConfig = object({
labelMatchers = list(list(string))
exactMatch = optional(bool, false)
bidirectionalLabelMatch = optional(bool, false)
priority = optional(number, 999)
enableDynamicLabels = optional(bool, false)
ec2DynamicLabelsPolicy = optional(any, null)
})
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, osx, 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 replaces the module default scale-up retry errors"
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 creating instances. For spot, AWS recommends `price-capacity-optimized`; for on-demand, use `lowest-price` or `prioritized`. The AWS default is `lowest-price`."
instance\_type\_priorities: "A map of instance type to priority for the `prioritized` and `capacity-optimized-prioritized` allocation strategies. Lower numbers mean higher priority. If not provided, priorities are assigned based on the order of `instance_types`."
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, macOS Sequoia for osx, 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` disables 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."
lambda\_event\_source\_mapping\_batch\_size: "(Optional) Maximum number of records per Lambda invocation for this runner flavor. Overrides the module-level `lambda_event_source_mapping_batch_size` when set."
lambda\_event\_source\_mapping\_maximum\_batching\_window\_in\_seconds: "(Optional) Maximum seconds to gather records before invoking Lambda for this runner flavor. Overrides the module-level `lambda_event_source_mapping_maximum_batching_window_in_seconds` when set."
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."
license\_specifications: "Optional EC2 License Manager license configuration ARNs for the runner launch template. Required for macOS dedicated-host runners when the host resource group uses a Mac dedicated host license configuration."
use\_dedicated\_host: "Experimental! Can be removed / changed without trigger a major release. Whether to use EC2 dedicated hosts for the runners. Needed for macos runners Note that using dedicated hosts can increase cost significantly."
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`, `volume_initialization_rate`."
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)."
iam\_overrides: "Allows to (optionally) override the instance profile and runner role created by the module. Set `override_instance_profile` to true and provide the `instance_profile_name` to use an existing instance profile. Set `override_runner_role` to true and provide the `runner_role_arn` to use an existing role for the runner instances."
}
matcherConfig: {
labelMatchers: "The list of list of labels supported by the runner configuration. `[[self-hosted, linux, x64, example]]`"
exactMatch: "DEPRECATED: Use `bidirectionalLabelMatch` instead. 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. Note: this only checks that workflow labels are a subset of runner labels, not the reverse."
bidirectionalLabelMatch: "If set to true, the runner labels and workflow job labels must be an exact two-way match (same set, any order, no extras or missing labels). This is stricter than `exactMatch` which only checks that workflow labels are a subset of runner labels. 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."
enableDynamicLabels: "Experimental! When true the dispatcher allows `ghr-*` dynamic labels for jobs routed to this runner. Default false."
awsDynamicLabelsPolicy: "Optional AWS dynamic label policy evaluated by the dispatcher. Only effective when `enableDynamicLabels = true`. Jobs whose provider dynamic labels violate every matching runner's policy are rejected with a 202 (a warning is logged). Evaluation: keys in `blocked_keys` are always rejected; keys in `restricted_keys` are allowed only when their value passes the rule; unlisted keys are allowed. Schema: `{ blocked_keys = [], restricted_keys = { = { allowed = [globs], denied = [globs], max = number|string } } }`. Keys use the dynamic label suffix, e.g. `instance-type` for `ghr-ec2-instance-type`."
}
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_type_priorities = optional(map(number), null)
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)
lambda_event_source_mapping_batch_size = optional(number, null)
lambda_event_source_mapping_maximum_batching_window_in_seconds = optional(number, null)
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 = optional(number)
threads_per_core = optional(number)
amd_sev_snp = optional(string)
nested_virtualization = optional(string)
}), 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)
license_specifications = optional(list(object({
license_configuration_arn = string
})), [])
use_dedicated_host = optional(bool, false)
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_initialization_rate = 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)
}), {})
iam_overrides = optional(object({
override_instance_profile = optional(bool, null)
instance_profile_name = optional(string, null)
override_runner_role = optional(bool, null)
runner_role_arn = optional(string, null)
}), {
override_instance_profile = false
instance_profile_name = null
override_runner_role = false
runner_role_arn = null
})
})
matcherConfig = object({
labelMatchers = list(list(string))
exactMatch = optional(bool, false)
bidirectionalLabelMatch = optional(bool, false)
priority = optional(number, 999)
enableDynamicLabels = optional(bool, false)
awsDynamicLabelsPolicy = optional(any, null)
})
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/variables.tf b/modules/multi-runner/variables.tf index 0ff62b0083..df6fb77473 100644 --- a/modules/multi-runner/variables.tf +++ b/modules/multi-runner/variables.tf @@ -208,7 +208,7 @@ variable "multi_runner_config" { bidirectionalLabelMatch = optional(bool, false) priority = optional(number, 999) enableDynamicLabels = optional(bool, false) - ec2DynamicLabelsPolicy = optional(any, null) + awsDynamicLabelsPolicy = optional(any, null) }) redrive_build_queue = optional(object({ enabled = bool @@ -233,7 +233,7 @@ variable "multi_runner_config" { 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" + scale_errors: "List of AWS error codes that should trigger retry during scale up. This list replaces the module default scale-up retry errors" 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." @@ -287,7 +287,7 @@ variable "multi_runner_config" { bidirectionalLabelMatch: "If set to true, the runner labels and workflow job labels must be an exact two-way match (same set, any order, no extras or missing labels). This is stricter than `exactMatch` which only checks that workflow labels are a subset of runner labels. 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." enableDynamicLabels: "Experimental! When true the dispatcher allows `ghr-*` dynamic labels for jobs routed to this runner. Default false." - ec2DynamicLabelsPolicy: "Optional policy for `ghr-ec2-*` labels evaluated by the dispatcher. Only effective when `enableDynamicLabels = true`. Jobs whose EC2 dynamic labels violate every matching runner's policy are rejected with a 202 (a warning is logged). Evaluation: keys in `blocked_keys` are always rejected; keys in `restricted_keys` are allowed only when their value passes the rule; unlisted keys are allowed. Schema: `{ blocked_keys = [], restricted_keys = { = { allowed = [globs], denied = [globs], max = number|string } } }`. Keys use the dynamic label suffix, e.g. `instance-type` for `ghr-ec2-instance-type`." + awsDynamicLabelsPolicy: "Optional AWS dynamic label policy evaluated by the dispatcher. Only effective when `enableDynamicLabels = true`. Jobs whose provider dynamic labels violate every matching runner's policy are rejected with a 202 (a warning is logged). Evaluation: keys in `blocked_keys` are always rejected; keys in `restricted_keys` are allowed only when their value passes the rule; unlisted keys are allowed. Schema: `{ blocked_keys = [], restricted_keys = { = { allowed = [globs], denied = [globs], max = number|string } } }`. Keys use the dynamic label suffix, e.g. `instance-type` for `ghr-ec2-instance-type`." } 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." } diff --git a/modules/runners/README.md b/modules/runners/README.md index 31e4da9239..0a1c0c53cd 100644 --- a/modules/runners/README.md +++ b/modules/runners/README.md @@ -165,10 +165,11 @@ yarn run dist | [github\_app\_parameters](#input\_github\_app\_parameters) | Parameter Store for GitHub App Parameters. |
object({
key_base64 = map(string)
id = map(string)
})
| n/a | yes | | [iam\_overrides](#input\_iam\_overrides) | This map provides the possibility to override some IAM defaults. The following attributes are supported: `instance_profile_name` overrides the instance profile name used in the launch template. `runner_role_arn` overrides the IAM role ARN used for the runner instances. |
object({
override_instance_profile = optional(bool, null)
instance_profile_name = optional(string, null)
override_runner_role = optional(bool, null)
runner_role_arn = optional(string, null)
})
|
{
"instance_profile_name": null,
"override_instance_profile": false,
"override_runner_role": false,
"runner_role_arn": null
}
| no | | [idle\_config](#input\_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. |
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 to use `capacity-optimized` however the AWS default is `lowest-price`. | `string` | `"lowest-price"` | no | +| [instance\_allocation\_strategy](#input\_instance\_allocation\_strategy) | The allocation strategy for creating instances. For spot, AWS recommends `price-capacity-optimized`; for on-demand, use `lowest-price` or `prioritized`. 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 | | [instance\_profile\_path](#input\_instance\_profile\_path) | The path that will be added to the instance\_profile, if not set the prefix will be used. | `string` | `null` | no | | [instance\_target\_capacity\_type](#input\_instance\_target\_capacity\_type) | Default lifecycle used runner instances, can be either `spot` or `on-demand`. | `string` | `"spot"` | no | +| [instance\_type\_priorities](#input\_instance\_type\_priorities) | A map of instance type to priority for the `prioritized` and `capacity-optimized-prioritized` allocation strategies. Lower numbers mean higher priority. If not provided, priorities are assigned based on the order of `instance_types`. | `map(number)` | `null` | no | | [instance\_types](#input\_instance\_types) | List of instance types for the action runner. Defaults are based on runner\_os (al2023 for linux, macOS Sequoia for osx, Windows Server Core for win). | `list(string)` | `null` | no | | [job\_retry](#input\_job\_retry) | 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.

`enable`: Enable or disable the job retry feature.
`delay_in_seconds`: The delay in seconds before the job retry check lambda will check the job status.
`delay_backoff`: The backoff factor for the delay.
`lambda_memory_size`: Memory size limit in MB for the job retry check lambda.
'lambda\_reserved\_concurrent\_executions': Amount of reserved concurrent executions for the job retry check lambda function. A value of 0 disables lambda from being triggered and -1 removes any concurrency limitations.
`lambda_timeout`: Time out of the job retry check lambda in seconds.
`max_attempts`: The maximum number of attempts to retry the job. |
object({
enable = optional(bool, false)
delay_in_seconds = optional(number, 300)
delay_backoff = optional(number, 2)
lambda_memory_size = optional(number, 256)
lambda_reserved_concurrent_executions = optional(number, 1)

lambda_timeout = optional(number, 30)

max_attempts = optional(number, 1)
})
| `{}` | no | | [key\_name](#input\_key\_name) | Key pair name | `string` | `null` | no | @@ -226,7 +227,7 @@ yarn run dist | [runners\_maximum\_count](#input\_runners\_maximum\_count) | The maximum number of runners that will be created. Setting the variable to `-1` desiables the maximum check. | `number` | `3` | no | | [s3\_runner\_binaries](#input\_s3\_runner\_binaries) | Bucket details for cached GitHub binary. |
object({
arn = string
id = string
key = string
})
| n/a | yes | | [scale\_down\_schedule\_expression](#input\_scale\_down\_schedule\_expression) | Scheduler expression to check every x for scale down. | `string` | `"cron(*/5 * * * ? *)"` | no | -| [scale\_errors](#input\_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 | `list(string)` |
[
"UnfulfillableCapacity",
"MaxSpotInstanceCountExceeded",
"TargetCapacityLimitExceededException",
"RequestLimitExceeded",
"ResourceLimitExceeded",
"MaxSpotInstanceCountExceeded",
"MaxSpotFleetRequestCountExceeded",
"InsufficientInstanceCapacity",
"InsufficientCapacityOnHost"
]
| no | +| [scale\_errors](#input\_scale\_errors) | List of AWS error codes that should trigger retry during scale up. This list replaces the module default scale-up retry errors | `list(string)` |
[
"UnfulfillableCapacity",
"MaxSpotInstanceCountExceeded",
"TargetCapacityLimitExceededException",
"RequestLimitExceeded",
"ResourceLimitExceeded",
"MaxSpotInstanceCountExceeded",
"MaxSpotFleetRequestCountExceeded",
"InsufficientInstanceCapacity",
"InsufficientCapacityOnHost"
]
| no | | [scale\_up\_reserved\_concurrent\_executions](#input\_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. | `number` | `1` | no | | [sqs\_build\_queue](#input\_sqs\_build\_queue) | SQS queue to consume accepted build events. |
object({
arn = string
url = string
})
| n/a | yes | | [ssm\_housekeeper](#input\_ssm\_housekeeper) | Configuration for the SSM housekeeper lambda. This lambda deletes token / JIT config from SSM.

`schedule_expression`: is used to configure the schedule for the lambda.
`state`: state of the cloudwatch event rule. Valid values are `DISABLED`, `ENABLED`, and `ENABLED_WITH_ALL_CLOUDTRAIL_MANAGEMENT_EVENTS`.
`lambda_memory_size`: lambda memory size limit.
`lambda_timeout`: timeout for the lambda in seconds.
`config`: configuration for the lambda function. Token path will be read by default from the module. |
object({
schedule_expression = optional(string, "rate(1 day)")
state = optional(string, "ENABLED")
lambda_memory_size = optional(number, 512)
lambda_timeout = optional(number, 60)
config = object({
tokenPath = optional(string)
minimumDaysOld = optional(number, 1)
dryRun = optional(bool, false)
})
})
|
{
"config": {}
}
| no | diff --git a/modules/runners/pool/README.md b/modules/runners/pool/README.md index 401c7f2cc8..24f9de5811 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
})
use_dedicated_host = bool
})
runners_maximum_count = number
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 = 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 | ## Outputs diff --git a/modules/runners/pool/main.tf b/modules/runners/pool/main.tf index 1871437c59..29ec0023b4 100644 --- a/modules/runners/pool/main.tf +++ b/modules/runners/pool/main.tf @@ -231,6 +231,7 @@ resource "aws_scheduler_schedule" "pool" { role_arn = aws_iam_role.scheduler.arn input = jsonencode({ poolSize = each.value.size + type = "ec2" }) } } diff --git a/modules/runners/scale-up.tf b/modules/runners/scale-up.tf index cbc9beaa64..b8abf01d14 100644 --- a/modules/runners/scale-up.tf +++ b/modules/runners/scale-up.tf @@ -54,6 +54,7 @@ resource "aws_lambda_function" "scale_up" { RUNNER_LABELS = lower(join(",", var.runner_labels)) RUNNER_GROUP_NAME = var.runner_group_name RUNNER_NAME_PREFIX = var.runner_name_prefix + RUNNER_PROVIDER_TYPE = "ec2" RUNNERS_MAXIMUM_COUNT = var.runners_maximum_count POWERTOOLS_SERVICE_NAME = "${var.prefix}-scale-up" SSM_TOKEN_PATH = local.token_path diff --git a/modules/runners/variables.tf b/modules/runners/variables.tf index d4f265c062..08283ce65c 100644 --- a/modules/runners/variables.tf +++ b/modules/runners/variables.tf @@ -772,7 +772,7 @@ variable "enable_on_demand_failover_for_errors" { } variable "scale_errors" { - description = "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" + description = "List of AWS error codes that should trigger retry during scale up. This list replaces the module default scale-up retry errors" type = list(string) default = [ "UnfulfillableCapacity", diff --git a/modules/termination-watcher/notification/README.md b/modules/termination-watcher/notification/README.md index 1a26de3bce..31df74586f 100644 --- a/modules/termination-watcher/notification/README.md +++ b/modules/termination-watcher/notification/README.md @@ -22,10 +22,14 @@ | Name | Type | |------|------| +| [aws_cloudwatch_event_rule.ec2_instance_state_change](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudwatch_event_rule) | resource | | [aws_cloudwatch_event_rule.spot_instance_termination_warning](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudwatch_event_rule) | resource | | [aws_cloudwatch_event_target.main](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudwatch_event_target) | resource | +| [aws_cloudwatch_event_target.state_change](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudwatch_event_target) | resource | | [aws_iam_role_policy.lambda_policy](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | +| [aws_iam_role_policy.ssm_policy](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | | [aws_lambda_permission.main](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/lambda_permission) | resource | +| [aws_lambda_permission.state_change](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/lambda_permission) | resource | ## Inputs diff --git a/modules/termination-watcher/termination/README.md b/modules/termination-watcher/termination/README.md index 22912b9646..32b32aa54e 100644 --- a/modules/termination-watcher/termination/README.md +++ b/modules/termination-watcher/termination/README.md @@ -25,6 +25,7 @@ | [aws_cloudwatch_event_rule.spot_instance_termination](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudwatch_event_rule) | resource | | [aws_cloudwatch_event_target.main](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudwatch_event_target) | resource | | [aws_iam_role_policy.lambda_policy](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | +| [aws_iam_role_policy.ssm_policy](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | | [aws_lambda_permission.main](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/lambda_permission) | resource | ## Inputs diff --git a/modules/webhook/README.md b/modules/webhook/README.md index 27834cb207..e63a8c01b7 100644 --- a/modules/webhook/README.md +++ b/modules/webhook/README.md @@ -89,7 +89,7 @@ yarn run dist | [repository\_white\_list](#input\_repository\_white\_list) | List of github repository full names (owner/repo\_name) that will be allowed to use the github app. Leave empty for no filtering. | `list(string)` | `[]` | no | | [role\_path](#input\_role\_path) | The path that will be added to the role; if not set, the environment name 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 | -| [runner\_matcher\_config](#input\_runner\_matcher\_config) | SQS queue to publish accepted build events based on the runner type. When exact match is disabled the webhook accepts the event if one of the workflow job labels is part of the matcher. The priority defines the order the matchers are applied. Optional `matcherConfig.enableDynamicLabels` and `matcherConfig.ec2DynamicLabelsPolicy` are evaluated by the dispatcher to gate `ghr-ec2-*` labels per runner. The policy supports `blocked_keys = []` and `restricted_keys = { = { allowed = [globs], denied = [globs], max = number|string } }`; keys use the `ghr-ec2-*` suffix form, for example `instance-type`. |
map(object({
arn = string
id = string
matcherConfig = object({
labelMatchers = list(list(string))
exactMatch = bool
bidirectionalLabelMatch = optional(bool, false)
priority = optional(number, 999)
enableDynamicLabels = optional(bool, false)
ec2DynamicLabelsPolicy = optional(any, null)
})
}))
| n/a | yes | +| [runner\_matcher\_config](#input\_runner\_matcher\_config) | SQS queue to publish accepted build events based on the runner type. `runnerProvider` defaults to `ec2`; EC2 is the only provider currently implemented. When exact match is disabled the webhook accepts the event if one of the workflow job labels is part of the matcher. The priority defines the order the matchers are applied. Optional `matcherConfig.enableDynamicLabels` and `matcherConfig.awsDynamicLabelsPolicy` are evaluated by the dispatcher to gate provider dynamic labels per runner. The policy supports `blocked_keys = []` and `restricted_keys = { = { allowed = [globs], denied = [globs], max = number|string } }`; keys use the provider dynamic label suffix form, for example `instance-type` for `ghr-ec2-instance-type`. |
map(object({
arn = string
id = string
runnerProvider = optional(string, "ec2")
matcherConfig = object({
labelMatchers = list(list(string))
exactMatch = bool
bidirectionalLabelMatch = optional(bool, false)
priority = optional(number, 999)
enableDynamicLabels = optional(bool, false)
awsDynamicLabelsPolicy = optional(any, null)
})
}))
| n/a | yes | | [ssm\_paths](#input\_ssm\_paths) | The root path used in SSM to store configuration and secrets. |
object({
root = string
webhook = 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 | | [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 | diff --git a/modules/webhook/variables.tf b/modules/webhook/variables.tf index 6f4f17b429..fcdb7a7dcf 100644 --- a/modules/webhook/variables.tf +++ b/modules/webhook/variables.tf @@ -23,23 +23,31 @@ variable "tags" { } variable "runner_matcher_config" { - description = "SQS queue to publish accepted build events based on the runner type. When exact match is disabled the webhook accepts the event if one of the workflow job labels is part of the matcher. The priority defines the order the matchers are applied. Optional `matcherConfig.enableDynamicLabels` and `matcherConfig.ec2DynamicLabelsPolicy` are evaluated by the dispatcher to gate `ghr-ec2-*` labels per runner. The policy supports `blocked_keys = []` and `restricted_keys = { = { allowed = [globs], denied = [globs], max = number|string } }`; keys use the `ghr-ec2-*` suffix form, for example `instance-type`." + description = "SQS queue to publish accepted build events based on the runner type. `runnerProvider` defaults to `ec2`; EC2 is the only provider currently implemented. When exact match is disabled the webhook accepts the event if one of the workflow job labels is part of the matcher. The priority defines the order the matchers are applied. Optional `matcherConfig.enableDynamicLabels` and `matcherConfig.awsDynamicLabelsPolicy` are evaluated by the dispatcher to gate provider dynamic labels per runner. The policy supports `blocked_keys = []` and `restricted_keys = { = { allowed = [globs], denied = [globs], max = number|string } }`; keys use the provider dynamic label suffix form, for example `instance-type` for `ghr-ec2-instance-type`." type = map(object({ - arn = string - id = string + arn = string + id = string + runnerProvider = optional(string, "ec2") matcherConfig = object({ labelMatchers = list(list(string)) exactMatch = bool bidirectionalLabelMatch = optional(bool, false) priority = optional(number, 999) enableDynamicLabels = optional(bool, false) - ec2DynamicLabelsPolicy = optional(any, null) + awsDynamicLabelsPolicy = optional(any, null) }) })) validation { condition = try(var.runner_matcher_config.matcherConfig.priority, 999) >= 0 && try(var.runner_matcher_config.matcherConfig.priority, 999) < 1000 error_message = "The priority of the matcher must be between 0 and 999." } + validation { + condition = alltrue([ + for config in values(var.runner_matcher_config) : + lower(trimspace(config.runnerProvider)) == "ec2" + ]) + error_message = "runnerProvider must be ec2." + } } variable "lambda_zip" { diff --git a/modules/webhook/webhook.tf b/modules/webhook/webhook.tf index de48192ea3..84a89bbc93 100644 --- a/modules/webhook/webhook.tf +++ b/modules/webhook/webhook.tf @@ -1,6 +1,11 @@ locals { # config with combined key and order - runner_matcher_config = { for k, v in var.runner_matcher_config : format("%03d-%s", v.matcherConfig.priority, k) => merge(v, { key = k }) } + runner_matcher_config = { + for k, v in var.runner_matcher_config : format("%03d-%s", v.matcherConfig.priority, k) => merge(v, { + key = k + runnerProvider = lower(trimspace(v.runnerProvider)) + }) + } # sorted list runner_matcher_config_sorted = [for k in sort(keys(local.runner_matcher_config)) : local.runner_matcher_config[k]] diff --git a/variables.tf b/variables.tf index 71e4d7df06..4af2ab4cd1 100644 --- a/variables.tf +++ b/variables.tf @@ -311,7 +311,7 @@ variable "enable_runner_on_demand_failover_for_errors" { } variable "scale_errors" { - description = "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" + description = "List of AWS error codes that should trigger retry during scale up. This list replaces the module default scale-up retry errors" type = list(string) default = [ "UnfulfillableCapacity", @@ -729,14 +729,15 @@ variable "enable_dynamic_labels" { default = false } -variable "ec2_dynamic_labels_policy" { +variable "aws_dynamic_labels_policy" { description = <<-EOT Experimental! Can be removed / changed without trigger a major release. - Optional policy for dynamic EC2 override labels evaluated by the webhook - dispatcher. Only effective when `enable_dynamic_labels = true`. + Optional AWS dynamic label policy evaluated by the webhook dispatcher. + Only effective when `enable_dynamic_labels = true`. - Jobs whose EC2 dynamic labels violate the policy are rejected with a 202 and a - warning is logged. + Jobs whose provider-specific dynamic labels violate the policy are rejected + with a 202 and a warning is logged. Currently this policy applies to EC2 + override labels using the `ghr-ec2-*` prefix. Evaluation: 1. Keys in `blocked_keys` are always rejected. @@ -748,8 +749,8 @@ variable "ec2_dynamic_labels_policy" { - `restricted_keys`: map of key to value rule: `{ allowed = [globs], denied = [globs], max = number|string }`. - Keys use the `ghr-ec2-*` dynamic label suffix, not the full label. For example, use - `instance-type` for `ghr-ec2-instance-type`. + Keys use the provider dynamic label suffix, not the full label. For example, + use `instance-type` for `ghr-ec2-instance-type`. EOT type = any default = null