Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
46 commits
Select commit Hold shift + click to select a range
4964069
refactor(aws): split ec2 runner module
edersonbrilhante Jul 7, 2026
b2f96f3
refactor(scale-runners): split ec2 scale-up provider
edersonbrilhante Jul 7, 2026
344fbeb
refactor(scale-runners): split ec2 scale-down provider
edersonbrilhante Jul 7, 2026
8c8d9e4
refactor(pool): split ec2 pool provider
edersonbrilhante Jul 7, 2026
b750300
refactor(webhook): decouple dynamic label dispatch
edersonbrilhante Jul 7, 2026
3a89176
chore(terraform): wire aws runner provider defaults
edersonbrilhante Jul 7, 2026
9bf1f61
docs: auto update terraform docs
github-actions[bot] Jul 7, 2026
83b917a
refactor(tests): keep provider tests in existing files
edersonbrilhante Jul 7, 2026
cf33d93
docs: auto update terraform docs
github-actions[bot] Jul 7, 2026
4f71c2a
refactor(tests): keep dispatch label tests in place
edersonbrilhante Jul 7, 2026
6069130
refactor(terraform): remove idle config provider type
edersonbrilhante Jul 7, 2026
a960c31
docs(config): remove idle config provider type
edersonbrilhante Jul 7, 2026
161c26b
test(pool): remove redundant ec2 provider assertion
edersonbrilhante Jul 7, 2026
0f6ed81
fix(scale-up): default blank runner provider type
edersonbrilhante Jul 7, 2026
e6d42f5
chore(pool): format available runner count
edersonbrilhante Jul 14, 2026
7c71a4d
docs: auto update terraform docs
github-actions[bot] Jul 14, 2026
742aa8f
fix(runners): preserve provider compatibility
edersonbrilhante Jul 14, 2026
f776788
docs(runners): clarify future microvm support
edersonbrilhante Jul 14, 2026
e593a3f
test(runners): limit provider normalization to ec2
edersonbrilhante Jul 14, 2026
3805873
fix(runners): align scale-down provider contract
edersonbrilhante Jul 14, 2026
443b08c
fix(webhook): restrict runner provider to ec2
edersonbrilhante Jul 14, 2026
384861a
fix(runners): restrict provider type to ec2
edersonbrilhante Jul 14, 2026
78e59bc
refactor(runners): simplify provider selection
edersonbrilhante Jul 14, 2026
4129c9e
refactor(runners): centralize default provider
edersonbrilhante Jul 14, 2026
cf92c86
fix(runners): align scale-down config contract
edersonbrilhante Jul 14, 2026
b58312b
refactor(runners): centralize provider validation
edersonbrilhante Jul 14, 2026
7b1c735
refactor(pool): name provider functions
edersonbrilhante Jul 14, 2026
853762b
refactor(pool): simplify provider creation
edersonbrilhante Jul 14, 2026
7496329
refactor(pool): map provider factories
edersonbrilhante Jul 14, 2026
c2f5fca
refactor(pool): remove provider type alias
edersonbrilhante Jul 14, 2026
88f1791
refactor(runners): derive provider type values
edersonbrilhante Jul 14, 2026
09f43b6
refactor(pool): rename provider factory
edersonbrilhante Jul 14, 2026
506be55
refactor(pool): remove redundant provider type
edersonbrilhante Jul 14, 2026
5299d51
refactor(pool): inline scale errors config
edersonbrilhante Jul 14, 2026
870ce90
refactor(pool): move provider functions to module scope
edersonbrilhante Jul 14, 2026
f6fc43d
refactor(pool): expose provider identity
edersonbrilhante Jul 14, 2026
46be26c
refactor(pool): load config when creating runners
edersonbrilhante Jul 14, 2026
0698b34
refactor(scale-runners): simplify ec2 providers
edersonbrilhante Jul 14, 2026
7f7f078
refactor(runners): assign provider identity in registries
edersonbrilhante Jul 14, 2026
ef544f8
refactor(runners): consolidate provider registry
edersonbrilhante Jul 14, 2026
556c2bb
test(runners): cover provider registry
edersonbrilhante Jul 14, 2026
61df834
refactor(runners): share provider type contract
edersonbrilhante Jul 14, 2026
f02d68d
refactor(runners): centralize provider contract
edersonbrilhante Jul 14, 2026
a4692b8
fix(webhook): require matcher runner provider
edersonbrilhante Jul 14, 2026
688e578
fix(webhook): keep matcher runner provider optional
edersonbrilhante Jul 14, 2026
4f27502
refactor(webhook): import provider type directly
edersonbrilhante Jul 14, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions README.md

Large diffs are not rendered by default.

6 changes: 4 additions & 2 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 = {
Expand All @@ -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.
Expand Down
1 change: 1 addition & 0 deletions lambdas/functions/control-plane/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand Down
6 changes: 3 additions & 3 deletions lambdas/functions/control-plane/src/aws/runners.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
2 changes: 1 addition & 1 deletion lambdas/functions/control-plane/src/github/octokit.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down
7 changes: 4 additions & 3 deletions lambdas/functions/control-plane/src/lambda.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 });
});
});
Expand Down
3 changes: 2 additions & 1 deletion lambdas/functions/control-plane/src/lambda.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down
2 changes: 1 addition & 1 deletion lambdas/functions/control-plane/src/local-pool.ts
Original file line number Diff line number Diff line change
@@ -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);
Expand Down
1 change: 1 addition & 0 deletions lambdas/functions/control-plane/src/modules.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
118 changes: 118 additions & 0 deletions lambdas/functions/control-plane/src/pool/ec2-pool.ts
Original file line number Diff line number Diff line change
@@ -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<string, number>)
: 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<RunnerList[]> {
return await listEC2Runners({
environment,
runnerOwner,
runnerType,
statuses: ['running'],
});
}

async function createEc2PoolRunners({
githubRunnerConfig,
numberOfRunners,
githubInstallationClient,
}: CreatePoolRunnersInput): Promise<string[]> {
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<PoolRunnerProvider, 'type'> {
return {
listRunners: listEc2PoolRunners,
countAvailableRunners: calculateEc2PoolSize,
createRunners: createEc2PoolRunners,
};
}

export function calculateEc2PoolSize(
ec2runners: RunnerList[],
runnerStatus: Map<string, RunnerStatus>,
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;
}
31 changes: 31 additions & 0 deletions lambdas/functions/control-plane/src/pool/pool-provider.ts
Original file line number Diff line number Diff line change
@@ -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<TRunner = unknown> extends RunnerProvider {
listRunners(input: ListPoolRunnersInput): Promise<TRunner[]>;
countAvailableRunners(
runners: TRunner[],
runnerStatus: Map<string, RunnerStatus>,
includeBusyRunners: boolean,
): number;
createRunners(input: CreatePoolRunnersInput): Promise<string[]>;
}
Loading
Loading