From 2b8c476ffce093c6a1641ee7200000db846624b3 Mon Sep 17 00:00:00 2001 From: Sam Hirst Date: Thu, 27 Aug 2026 11:08:17 +0100 Subject: [PATCH] fix: support runner subnets in the same availability zone Group subnets into AZ-safe fleet requests and distribute capacity across them. Carry retryable failures forward to alternative subnets and add the required DescribeSubnets permissions. --- README.md | 2 +- .../aws/ec2/src/control-plane/runners.test.ts | 99 +++++++++++++++- .../aws/ec2/src/control-plane/runners.ts | 108 ++++++++++++++++++ modules/multi-runner/README.md | 2 +- modules/multi-runner/variables.tf | 2 +- modules/runners/README.md | 2 +- modules/runners/policies/lambda-scale-up.json | 1 + .../runners/pool/policies/lambda-pool.json | 1 + modules/runners/variables.tf | 2 +- variables.tf | 2 +- 10 files changed, 210 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index f097d3ebd2..dde6d0f6fd 100644 --- a/README.md +++ b/README.md @@ -228,7 +228,7 @@ Join our discord community via [this invite link](https://discord.gg/bxgXW8jJGh) | [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 | -| [subnet\_ids](#input\_subnet\_ids) | List of subnets in which the action runner instances will be launched. The subnets need to exist in the configured VPC (`vpc_id`), and must reside in different availability zones (see https://github.com/github-aws-runners/terraform-aws-github-runner/issues/2904) | `list(string)` | n/a | yes | +| [subnet\_ids](#input\_subnet\_ids) | List of subnets in which the action runner instances will be launched. The subnets must exist in the configured VPC (`vpc_id`). | `list(string)` | n/a | yes | | [syncer\_lambda\_s3\_key](#input\_syncer\_lambda\_s3\_key) | S3 key for syncer lambda function. Required if using an S3 bucket to specify lambdas. | `string` | `null` | no | | [syncer\_lambda\_s3\_object\_version](#input\_syncer\_lambda\_s3\_object\_version) | S3 object version for syncer lambda function. Useful if S3 versioning is enabled on source bucket. | `string` | `null` | no | | [tags](#input\_tags) | Map of tags that will be added to created resources. By default resources will be tagged with name and environment. | `map(string)` | `{}` | no | diff --git a/lambdas/libs/compute-providers/aws/ec2/src/control-plane/runners.test.ts b/lambdas/libs/compute-providers/aws/ec2/src/control-plane/runners.test.ts index 738c6da13d..b52d75fe37 100644 --- a/lambdas/libs/compute-providers/aws/ec2/src/control-plane/runners.test.ts +++ b/lambdas/libs/compute-providers/aws/ec2/src/control-plane/runners.test.ts @@ -9,6 +9,8 @@ import { DeleteTagsCommand, DescribeInstancesCommand, type DescribeInstancesResult, + DescribeSubnetsCommand, + type DescribeSubnetsResult, EC2Client, FleetOnDemandAllocationStrategy, RunInstancesCommand, @@ -77,6 +79,18 @@ const mockRunningInstancesJit: DescribeInstancesResult = { }, ], }; +const mockDefaultSubnets: DescribeSubnetsResult = { + Subnets: [ + { SubnetId: 'subnet-123', AvailabilityZoneId: 'euw1-az1' }, + { SubnetId: 'subnet-456', AvailabilityZoneId: 'euw1-az2' }, + ], +}; + +function getSubnetIdsFromFleetRequest(request: CreateFleetCommandInput): string[] { + const overrides = request.LaunchTemplateConfigs?.[0].Overrides ?? []; + const subnetIds = overrides.flatMap(({ SubnetId }) => (SubnetId ? [SubnetId] : [])); + return [...new Set(subnetIds)]; +} describe('list instances', () => { beforeEach(() => { @@ -338,6 +352,7 @@ describe('create runner', () => { mockEC2Client.reset(); mockSSMClient.reset(); + mockEC2Client.on(DescribeSubnetsCommand).resolves(mockDefaultSubnets); mockEC2Client.on(CreateFleetCommand).resolves({ Instances: [{ InstanceIds: ['i-1234'] }] }); mockSSMClient.on(GetParameterCommand).resolves({}); }); @@ -345,6 +360,10 @@ describe('create runner', () => { it.each(RUNNER_TYPES)('calls create fleet of 1 instance with the default config for %p', async (type: RunnerType) => { await createRunner(createRunnerConfig({ ...defaultRunnerConfig, type: type })); + expect(mockEC2Client).toHaveReceivedCommandWith(DescribeSubnetsCommand, { + SubnetIds: ['subnet-123', 'subnet-456'], + }); + expect(mockEC2Client).toHaveReceivedCommandTimes(CreateFleetCommand, 1); expect(mockEC2Client).toHaveReceivedCommandWith(CreateFleetCommand, { ...expectedCreateFleetRequest({ ...defaultExpectedFleetRequestValues, @@ -353,6 +372,71 @@ describe('create runner', () => { }); }); + it('partitions batch capacity across subnet sets that contain at most one subnet per Availability Zone', async () => { + mockEC2Client.on(DescribeSubnetsCommand).resolves({ + Subnets: [ + { SubnetId: 'subnet-123', AvailabilityZoneId: 'euw1-az1' }, + { SubnetId: 'subnet-456', AvailabilityZoneId: 'euw1-az1' }, + { SubnetId: 'subnet-789', AvailabilityZoneId: 'euw1-az2' }, + ], + }); + mockEC2Client + .on(CreateFleetCommand) + .resolvesOnce({ Instances: [{ InstanceIds: ['i-1234'] }] }) + .resolvesOnce({ Instances: [{ InstanceIds: ['i-5678'] }] }); + + const result = await createRunner({ + ...createRunnerConfig(defaultRunnerConfig), + numberOfRunners: 2, + subnets: ['subnet-123', 'subnet-456', 'subnet-789'], + }); + + expect(result).toEqual({ + instances: ['i-1234', 'i-5678'], + retryableErrorCount: 0, + nonRetryableErrorCount: 0, + }); + expect(mockEC2Client).toHaveReceivedCommandTimes(CreateFleetCommand, 2); + const fleetRequests = mockEC2Client.commandCalls(CreateFleetCommand).map(({ args: [command] }) => command.input); + const subnetSets = fleetRequests.map(getSubnetIdsFromFleetRequest); + expect(subnetSets.map((subnets) => subnets.sort()).sort()).toEqual( + [ + ['subnet-123', 'subnet-789'], + ['subnet-456', 'subnet-789'], + ].sort(), + ); + }); + + it('carries retryable subnet address failures to the next same-AZ subnet set', async () => { + mockEC2Client.on(DescribeSubnetsCommand).resolves({ + Subnets: [ + { SubnetId: 'subnet-123', AvailabilityZoneId: 'euw1-az1' }, + { SubnetId: 'subnet-456', AvailabilityZoneId: 'euw1-az1' }, + ], + }); + mockEC2Client + .on(CreateFleetCommand) + .resolvesOnce({ Errors: [{ ErrorCode: 'InsufficientFreeAddressesInSubnet' }] }) + .resolvesOnce({ Instances: [{ InstanceIds: ['i-1234'] }] }); + + const result = await createRunner( + createRunnerConfig({ + ...defaultRunnerConfig, + scaleErrors: [...defaultRunnerConfig.scaleErrors, 'InsufficientFreeAddressesInSubnet'], + }), + ); + + expect(result).toEqual({ + instances: ['i-1234'], + retryableErrorCount: 0, + nonRetryableErrorCount: 0, + }); + expect(mockEC2Client).toHaveReceivedCommandTimes(CreateFleetCommand, 2); + const fleetRequests = mockEC2Client.commandCalls(CreateFleetCommand).map(({ args: [command] }) => command.input); + const attemptedSubnets = fleetRequests.flatMap(getSubnetIdsFromFleetRequest).sort(); + expect(attemptedSubnets).toEqual(['subnet-123', 'subnet-456']); + }); + it('calls create fleet of 2 instances with the correct config for org ', async () => { const instances = [{ InstanceIds: ['i-1234', 'i-5678'] }]; @@ -545,6 +629,7 @@ describe('create runner', () => { }, }); + expect(mockEC2Client).not.toHaveReceivedCommand(DescribeSubnetsCommand); expect(mockEC2Client).toHaveReceivedCommandWith(CreateFleetCommand, { LaunchTemplateConfigs: [ { @@ -767,6 +852,7 @@ describe('create runner with errors', () => { mockEC2Client.reset(); mockSSMClient.reset(); + mockEC2Client.on(DescribeSubnetsCommand).resolves(mockDefaultSubnets); mockSSMClient.on(PutParameterCommand).resolves({}); mockSSMClient.on(GetParameterCommand).resolves({}); mockEC2Client.on(CreateFleetCommand).resolves({ Instances: [] }); @@ -1013,6 +1099,7 @@ describe('create runner with errors fail over to OnDemand', () => { mockEC2Client.reset(); mockSSMClient.reset(); + mockEC2Client.on(DescribeSubnetsCommand).resolves(mockDefaultSubnets); mockSSMClient.on(PutParameterCommand).resolves({}); mockSSMClient.on(GetParameterCommand).resolves({}); mockEC2Client.on(CreateFleetCommand).resolves({ Instances: [] }); @@ -1032,7 +1119,7 @@ describe('create runner with errors fail over to OnDemand', () => { expect(mockEC2Client).toHaveReceivedCommandTimes(CreateFleetCommand, 2); // first call with spot failure - expect(mockEC2Client).toHaveReceivedNthCommandWith(1, CreateFleetCommand, { + expect(mockEC2Client).toHaveReceivedNthSpecificCommandWith(1, CreateFleetCommand, { ...expectedCreateFleetRequest({ ...defaultExpectedFleetRequestValues, totalTargetCapacity: 1, @@ -1041,7 +1128,7 @@ describe('create runner with errors fail over to OnDemand', () => { }); // second call with with OnDemand fallback, allocation strategy defaults to lowest-price - expect(mockEC2Client).toHaveReceivedNthCommandWith(2, CreateFleetCommand, { + expect(mockEC2Client).toHaveReceivedNthSpecificCommandWith(2, CreateFleetCommand, { ...expectedCreateFleetRequest({ ...defaultExpectedFleetRequestValues, totalTargetCapacity: 1, @@ -1079,7 +1166,7 @@ describe('create runner with errors fail over to OnDemand', () => { expect(mockEC2Client).toHaveReceivedCommandTimes(CreateFleetCommand, 2); // first call with spot failure - expect(mockEC2Client).toHaveReceivedNthCommandWith(1, CreateFleetCommand, { + expect(mockEC2Client).toHaveReceivedNthSpecificCommandWith(1, CreateFleetCommand, { ...expectedCreateFleetRequest({ ...defaultExpectedFleetRequestValues, totalTargetCapacity: 2, @@ -1088,7 +1175,7 @@ describe('create runner with errors fail over to OnDemand', () => { }); // second call with with OnDemand failback, capacity is reduced by 1, allocation strategy defaults to lowest-price - expect(mockEC2Client).toHaveReceivedNthCommandWith(2, CreateFleetCommand, { + expect(mockEC2Client).toHaveReceivedNthSpecificCommandWith(2, CreateFleetCommand, { ...expectedCreateFleetRequest({ ...defaultExpectedFleetRequestValues, totalTargetCapacity: 1, @@ -1113,7 +1200,7 @@ describe('create runner with errors fail over to OnDemand', () => { expect(mockEC2Client).toHaveReceivedCommandTimes(CreateFleetCommand, 1); // first call with spot failure - expect(mockEC2Client).toHaveReceivedNthCommandWith(1, CreateFleetCommand, { + expect(mockEC2Client).toHaveReceivedNthSpecificCommandWith(1, CreateFleetCommand, { ...expectedCreateFleetRequest({ ...defaultExpectedFleetRequestValues, totalTargetCapacity: 2, @@ -1328,6 +1415,7 @@ describe('create runner with useDedicatedHost', () => { mockEC2Client.reset(); mockSSMClient.reset(); + mockEC2Client.on(DescribeSubnetsCommand).resolves(mockDefaultSubnets); mockEC2Client.on(RunInstancesCommand).resolves({ Instances: [{ InstanceId: 'i-dedicated-1' }], }); @@ -1344,6 +1432,7 @@ describe('create runner with useDedicatedHost', () => { }); expect(mockEC2Client).toHaveReceivedCommand(RunInstancesCommand); expect(mockEC2Client).not.toHaveReceivedCommand(CreateFleetCommand); + expect(mockEC2Client).not.toHaveReceivedCommand(DescribeSubnetsCommand); }); it('uses CreateFleet when useDedicatedHost is false', async () => { diff --git a/lambdas/libs/compute-providers/aws/ec2/src/control-plane/runners.ts b/lambdas/libs/compute-providers/aws/ec2/src/control-plane/runners.ts index 050804cce1..cb446751e6 100644 --- a/lambdas/libs/compute-providers/aws/ec2/src/control-plane/runners.ts +++ b/lambdas/libs/compute-providers/aws/ec2/src/control-plane/runners.ts @@ -6,6 +6,7 @@ import { DeleteTagsCommand, DescribeInstancesCommand, DescribeInstancesResult, + DescribeSubnetsCommand, RunInstancesCommand, type RunInstancesCommandInput, RunInstancesCommandOutput, @@ -32,6 +33,11 @@ interface Ec2Filter { Values: string[]; } +interface SubnetAllocation { + subnets: string[]; + targetCapacity: number; +} + type FleetError = NonNullable[number]; export async function listEC2Runners(filters: Ec2ListRunnerFilters | undefined = undefined): Promise { @@ -196,6 +202,57 @@ function isRetryableAwsErrorName(errorName: string, configuredRetryableErrors: s return configuredRetryableErrors.includes(errorName) || RETRYABLE_AWS_ERROR_NAMES.has(errorName); } +async function buildAzSafeSubnetSets(subnetIds: string[], ec2Client: EC2Client): Promise { + const uniqueSubnetIds = [...new Set(subnetIds)]; + if (uniqueSubnetIds.length <= 1) { + return [uniqueSubnetIds]; + } + + const response = await ec2Client.send(new DescribeSubnetsCommand({ SubnetIds: uniqueSubnetIds })); + const availabilityZoneBySubnet = new Map(); + for (const subnet of response.Subnets || []) { + const availabilityZone = subnet.AvailabilityZoneId || subnet.AvailabilityZone; + if (subnet.SubnetId && availabilityZone) { + availabilityZoneBySubnet.set(subnet.SubnetId, availabilityZone); + } + } + + const subnetsByAvailabilityZone = new Map(); + for (const subnetId of uniqueSubnetIds) { + const availabilityZone = availabilityZoneBySubnet.get(subnetId); + if (!availabilityZone) { + throw new Error(`Unable to resolve an Availability Zone for subnet '${subnetId}'.`); + } + const subnets = subnetsByAvailabilityZone.get(availabilityZone) || []; + subnets.push(subnetId); + subnetsByAvailabilityZone.set(availabilityZone, subnets); + } + + const subnetSetCount = Math.max(...[...subnetsByAvailabilityZone.values()].map((subnets) => subnets.length)); + const subnetSets = Array.from({ length: subnetSetCount }, (_, setIndex) => + [...subnetsByAvailabilityZone.values()].map((subnets) => subnets[setIndex % subnets.length]), + ); + + logger.debug('Resolved AZ-safe subnet sets.', { subnetSets }); + return subnetSets; +} + +function buildSubnetAllocations(subnetSets: string[][], targetCapacity: number): SubnetAllocation[] { + if (subnetSets.length <= 1) { + return [{ subnets: subnetSets[0], targetCapacity }]; + } + + const startIndex = Math.floor(Math.random() * subnetSets.length); + const orderedSubnetSets = subnetSets.map((_, index) => subnetSets[(startIndex + index) % subnetSets.length]); + const baseTargetCapacity = Math.floor(targetCapacity / orderedSubnetSets.length); + const remainder = targetCapacity % orderedSubnetSets.length; + + return orderedSubnetSets.map((subnets, index) => ({ + subnets, + targetCapacity: baseTargetCapacity + (index < remainder ? 1 : 0), + })); +} + // The instance_allocation_strategy variable accepts the union of spot and on-demand strategies, // so a value valid for one capacity type can be invalid for the other. AWS rejects CreateFleet // when the strategy is not valid for the target capacity type, so fall back to 'lowest-price' @@ -299,6 +356,57 @@ function buildRunInstancesOverrides( } export async function createRunner(runnerParameters: RunnerInputParameters): Promise { + if (runnerParameters.useDedicatedHost || runnerParameters.ec2OverrideConfig?.SubnetId) { + return await createRunnerForSubnetSet(runnerParameters); + } + + const ec2Client = getTracedAWSV3Client(new EC2Client({ region: process.env.AWS_REGION })); + let subnetSets: string[][]; + try { + subnetSets = await buildAzSafeSubnetSets(runnerParameters.subnets, ec2Client); + } catch (error) { + const retryable = isRetryableAwsError(error, runnerParameters.scaleErrors); + logger.warn('Failed to resolve runner subnet Availability Zones.', { + error: error as Error, + retryable, + }); + return failedCreateRunnerResult(runnerParameters.numberOfRunners, retryable); + } + + if (subnetSets.length === 1) { + return await createRunnerForSubnetSet({ ...runnerParameters, subnets: subnetSets[0] }); + } + + const result: CreateRunnerResult = { + instances: [], + retryableErrorCount: 0, + nonRetryableErrorCount: 0, + }; + let retryableCarry = 0; + + const allocations = buildSubnetAllocations(subnetSets, runnerParameters.numberOfRunners); + for (const allocation of allocations) { + const targetCapacity = allocation.targetCapacity + retryableCarry; + retryableCarry = 0; + if (targetCapacity === 0) { + continue; + } + + const allocationResult = await createRunnerForSubnetSet({ + ...runnerParameters, + subnets: allocation.subnets, + numberOfRunners: targetCapacity, + }); + result.instances.push(...allocationResult.instances); + result.nonRetryableErrorCount += allocationResult.nonRetryableErrorCount; + retryableCarry = allocationResult.retryableErrorCount; + } + + result.retryableErrorCount = retryableCarry; + return result; +} + +async function createRunnerForSubnetSet(runnerParameters: RunnerInputParameters): Promise { logger.debug('Runner configuration.', { runner: { configuration: { diff --git a/modules/multi-runner/README.md b/modules/multi-runner/README.md index 21fc8f441b..5ed2c2b7b3 100644 --- a/modules/multi-runner/README.md +++ b/modules/multi-runner/README.md @@ -202,7 +202,7 @@ module "multi-runner" { | [scale\_up\_lambda\_memory\_size](#input\_scale\_up\_lambda\_memory\_size) | Memory size limit in MB for scale\_up lambda. | `number` | `512` | 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")
})
| `{}` | 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 | -| [subnet\_ids](#input\_subnet\_ids) | List of subnets in which the action runners will be launched, the subnets needs to be subnets in the `vpc_id`. | `list(string)` | n/a | yes | +| [subnet\_ids](#input\_subnet\_ids) | List of subnets in which the action runner instances will be launched. The subnets must exist in the configured VPC (`vpc_id`). | `list(string)` | n/a | yes | | [syncer\_lambda\_s3\_key](#input\_syncer\_lambda\_s3\_key) | S3 key for syncer lambda function. Required if using S3 bucket to specify lambdas. | `string` | `null` | no | | [syncer\_lambda\_s3\_object\_version](#input\_syncer\_lambda\_s3\_object\_version) | S3 object version for syncer lambda function. Useful if S3 versioning is enabled on source bucket. | `string` | `null` | no | | [tags](#input\_tags) | Map of tags that will be added to created resources. By default resources will be tagged with name and environment. | `map(string)` | `{}` | no | diff --git a/modules/multi-runner/variables.tf b/modules/multi-runner/variables.tf index a47cd2a83c..64cf0aae93 100644 --- a/modules/multi-runner/variables.tf +++ b/modules/multi-runner/variables.tf @@ -589,7 +589,7 @@ variable "vpc_id" { } variable "subnet_ids" { - description = "List of subnets in which the action runners will be launched, the subnets needs to be subnets in the `vpc_id`." + description = "List of subnets in which the action runner instances will be launched. The subnets must exist in the configured VPC (`vpc_id`)." type = list(string) } diff --git a/modules/runners/README.md b/modules/runners/README.md index d228615edd..429c4e6c0c 100644 --- a/modules/runners/README.md +++ b/modules/runners/README.md @@ -232,7 +232,7 @@ yarn run dist | [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 | | [ssm\_paths](#input\_ssm\_paths) | The root path used in SSM to store configuration and secrets. |
object({
root = string
tokens = string
config = string
})
| n/a | yes | -| [subnet\_ids](#input\_subnet\_ids) | List of subnets in which the action runners will be launched, the subnets needs to be subnets in the `vpc_id`. | `list(string)` | n/a | yes | +| [subnet\_ids](#input\_subnet\_ids) | List of subnets in which the action runner instances will be launched. The subnets must exist in the configured VPC (`vpc_id`). | `list(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. | `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 | | [use\_dedicated\_host](#input\_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. | `bool` | `false` | no | diff --git a/modules/runners/policies/lambda-scale-up.json b/modules/runners/policies/lambda-scale-up.json index 851ecc34f5..a12ee66183 100644 --- a/modules/runners/policies/lambda-scale-up.json +++ b/modules/runners/policies/lambda-scale-up.json @@ -5,6 +5,7 @@ "Effect": "Allow", "Action": [ "ec2:DescribeInstances", + "ec2:DescribeSubnets", "ec2:DescribeLaunchTemplateVersions", "ec2:DescribeTags", "ec2:RunInstances", diff --git a/modules/runners/pool/policies/lambda-pool.json b/modules/runners/pool/policies/lambda-pool.json index 51afd73b50..5b16a2ebf4 100644 --- a/modules/runners/pool/policies/lambda-pool.json +++ b/modules/runners/pool/policies/lambda-pool.json @@ -5,6 +5,7 @@ "Effect": "Allow", "Action": [ "ec2:DescribeInstances", + "ec2:DescribeSubnets", "ec2:DescribeTags", "ec2:RunInstances", "ec2:CreateFleet", diff --git a/modules/runners/variables.tf b/modules/runners/variables.tf index 946f9abf30..97561472dc 100644 --- a/modules/runners/variables.tf +++ b/modules/runners/variables.tf @@ -31,7 +31,7 @@ variable "vpc_id" { } variable "subnet_ids" { - description = "List of subnets in which the action runners will be launched, the subnets needs to be subnets in the `vpc_id`." + description = "List of subnets in which the action runner instances will be launched. The subnets must exist in the configured VPC (`vpc_id`)." type = list(string) } diff --git a/variables.tf b/variables.tf index c4e1e9b5cf..2de4182ed6 100644 --- a/variables.tf +++ b/variables.tf @@ -9,7 +9,7 @@ variable "vpc_id" { } variable "subnet_ids" { - description = "List of subnets in which the action runner instances will be launched. The subnets need to exist in the configured VPC (`vpc_id`), and must reside in different availability zones (see https://github.com/github-aws-runners/terraform-aws-github-runner/issues/2904)" + description = "List of subnets in which the action runner instances will be launched. The subnets must exist in the configured VPC (`vpc_id`)." type = list(string) }