Skip to content

Commit a70f0ca

Browse files
feat: add input use_dedicated_host
1 parent ebbe471 commit a70f0ca

14 files changed

Lines changed: 343 additions & 0 deletions

File tree

lambdas/functions/control-plane/src/aws/runners.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,4 +70,5 @@ export interface RunnerInputParameters {
7070
tracingEnabled?: boolean;
7171
onDemandFailoverOnError?: string[];
7272
scaleErrors: string[];
73+
useDedicatedHost?: boolean;
7374
}

lambdas/functions/control-plane/src/aws/runners.test.ts

Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
DescribeInstancesCommand,
1111
type DescribeInstancesResult,
1212
EC2Client,
13+
RunInstancesCommand,
1314
SpotAllocationStrategy,
1415
TerminateInstancesCommand,
1516
} from '@aws-sdk/client-ec2';
@@ -964,6 +965,7 @@ interface RunnerConfig {
964965
onDemandFailoverOnError?: string[];
965966
scaleErrors: string[];
966967
source: LambdaRunnerSource;
968+
useDedicatedHost?: boolean;
967969
}
968970

969971
function createRunnerConfig(runnerConfig: RunnerConfig): RunnerInputParameters {
@@ -985,6 +987,7 @@ function createRunnerConfig(runnerConfig: RunnerConfig): RunnerInputParameters {
985987
onDemandFailoverOnError: runnerConfig.onDemandFailoverOnError,
986988
scaleErrors: runnerConfig.scaleErrors,
987989
source: runnerConfig.source,
990+
useDedicatedHost: runnerConfig.useDedicatedHost,
988991
};
989992
}
990993

@@ -1073,3 +1076,211 @@ function expectedCreateFleetRequest(expectedValues: ExpectedFleetRequestValues):
10731076

10741077
return request;
10751078
}
1079+
1080+
describe('create runner with useDedicatedHost', () => {
1081+
const dedicatedHostRunnerConfig: RunnerConfig = {
1082+
allocationStrategy: SpotAllocationStrategy.CAPACITY_OPTIMIZED,
1083+
capacityType: 'on-demand',
1084+
type: 'Org',
1085+
scaleErrors: [],
1086+
useDedicatedHost: true,
1087+
};
1088+
1089+
beforeEach(() => {
1090+
vi.clearAllMocks();
1091+
mockEC2Client.reset();
1092+
mockSSMClient.reset();
1093+
1094+
mockEC2Client.on(RunInstancesCommand).resolves({
1095+
Instances: [{ InstanceId: 'i-dedicated-1' }],
1096+
});
1097+
mockSSMClient.on(GetParameterCommand).resolves({});
1098+
});
1099+
1100+
it('uses RunInstances instead of CreateFleet when useDedicatedHost is true', async () => {
1101+
const result = await createRunner(createRunnerConfig(dedicatedHostRunnerConfig));
1102+
1103+
expect(result).toEqual(['i-dedicated-1']);
1104+
expect(mockEC2Client).toHaveReceivedCommand(RunInstancesCommand);
1105+
expect(mockEC2Client).not.toHaveReceivedCommand(CreateFleetCommand);
1106+
});
1107+
1108+
it('uses CreateFleet when useDedicatedHost is false', async () => {
1109+
mockEC2Client.on(CreateFleetCommand).resolves({ Instances: [{ InstanceIds: ['i-fleet-1'] }] });
1110+
1111+
const result = await createRunner(createRunnerConfig({
1112+
...dedicatedHostRunnerConfig,
1113+
useDedicatedHost: false,
1114+
}));
1115+
1116+
expect(result).toEqual(['i-fleet-1']);
1117+
expect(mockEC2Client).toHaveReceivedCommand(CreateFleetCommand);
1118+
expect(mockEC2Client).not.toHaveReceivedCommand(RunInstancesCommand);
1119+
});
1120+
1121+
it('uses CreateFleet when useDedicatedHost is undefined', async () => {
1122+
mockEC2Client.on(CreateFleetCommand).resolves({ Instances: [{ InstanceIds: ['i-fleet-1'] }] });
1123+
1124+
const result = await createRunner(createRunnerConfig({
1125+
...dedicatedHostRunnerConfig,
1126+
useDedicatedHost: undefined,
1127+
}));
1128+
1129+
expect(result).toEqual(['i-fleet-1']);
1130+
expect(mockEC2Client).toHaveReceivedCommand(CreateFleetCommand);
1131+
expect(mockEC2Client).not.toHaveReceivedCommand(RunInstancesCommand);
1132+
});
1133+
1134+
it('passes correct parameters to RunInstances', async () => {
1135+
await createRunner(createRunnerConfig(dedicatedHostRunnerConfig));
1136+
1137+
expect(mockEC2Client).toHaveReceivedCommandWith(RunInstancesCommand, {
1138+
LaunchTemplate: {
1139+
LaunchTemplateName: LAUNCH_TEMPLATE,
1140+
Version: '$Default',
1141+
},
1142+
InstanceType: 'm5.large',
1143+
MinCount: 1,
1144+
MaxCount: 1,
1145+
SubnetId: 'subnet-123',
1146+
TagSpecifications: [
1147+
{
1148+
ResourceType: 'instance',
1149+
Tags: [
1150+
{ Key: 'ghr:Application', Value: 'github-action-runner' },
1151+
{ Key: 'ghr:created_by', Value: 'scale-up-lambda' },
1152+
{ Key: 'ghr:Type', Value: 'Org' },
1153+
{ Key: 'ghr:Owner', Value: REPO_NAME },
1154+
],
1155+
},
1156+
{
1157+
ResourceType: 'volume',
1158+
Tags: [
1159+
{ Key: 'ghr:Application', Value: 'github-action-runner' },
1160+
{ Key: 'ghr:created_by', Value: 'scale-up-lambda' },
1161+
{ Key: 'ghr:Type', Value: 'Org' },
1162+
{ Key: 'ghr:Owner', Value: REPO_NAME },
1163+
],
1164+
},
1165+
],
1166+
});
1167+
});
1168+
1169+
it('creates multiple instances via RunInstances', async () => {
1170+
mockEC2Client.on(RunInstancesCommand).resolves({
1171+
Instances: [{ InstanceId: 'i-dedicated-1' }, { InstanceId: 'i-dedicated-2' }],
1172+
});
1173+
1174+
const result = await createRunner({
1175+
...createRunnerConfig(dedicatedHostRunnerConfig),
1176+
numberOfRunners: 2,
1177+
});
1178+
1179+
expect(result).toEqual(['i-dedicated-1', 'i-dedicated-2']);
1180+
expect(mockEC2Client).toHaveReceivedCommandWith(RunInstancesCommand, {
1181+
LaunchTemplate: {
1182+
LaunchTemplateName: LAUNCH_TEMPLATE,
1183+
Version: '$Default',
1184+
},
1185+
InstanceType: 'm5.large',
1186+
MinCount: 2,
1187+
MaxCount: 2,
1188+
SubnetId: 'subnet-123',
1189+
TagSpecifications: [
1190+
{
1191+
ResourceType: 'instance',
1192+
Tags: [
1193+
{ Key: 'ghr:Application', Value: 'github-action-runner' },
1194+
{ Key: 'ghr:created_by', Value: 'pool-lambda' },
1195+
{ Key: 'ghr:Type', Value: 'Org' },
1196+
{ Key: 'ghr:Owner', Value: REPO_NAME },
1197+
],
1198+
},
1199+
{
1200+
ResourceType: 'volume',
1201+
Tags: [
1202+
{ Key: 'ghr:Application', Value: 'github-action-runner' },
1203+
{ Key: 'ghr:created_by', Value: 'pool-lambda' },
1204+
{ Key: 'ghr:Type', Value: 'Org' },
1205+
{ Key: 'ghr:Owner', Value: REPO_NAME },
1206+
],
1207+
},
1208+
],
1209+
});
1210+
});
1211+
1212+
it('throws error when spot is used with dedicated host', async () => {
1213+
await expect(
1214+
createRunner(createRunnerConfig({
1215+
...dedicatedHostRunnerConfig,
1216+
capacityType: 'spot',
1217+
})),
1218+
).rejects.toThrow('Spot instances are not supported with RunInstances');
1219+
expect(mockEC2Client).not.toHaveReceivedCommand(RunInstancesCommand);
1220+
});
1221+
1222+
it('throws error when RunInstances returns no instances', async () => {
1223+
mockEC2Client.on(RunInstancesCommand).resolves({ Instances: [] });
1224+
1225+
await expect(
1226+
createRunner(createRunnerConfig(dedicatedHostRunnerConfig)),
1227+
).rejects.toThrow('RunInstances returned no instances for dedicated host.');
1228+
});
1229+
1230+
it('throws error when RunInstances fails', async () => {
1231+
mockEC2Client.on(RunInstancesCommand).rejects(new Error('EC2 error'));
1232+
1233+
await expect(
1234+
createRunner(createRunnerConfig(dedicatedHostRunnerConfig)),
1235+
).rejects.toThrow('EC2 error');
1236+
});
1237+
1238+
it('uses ami id override from ssm parameter', async () => {
1239+
const paramValue: GetParameterResult = {
1240+
Parameter: {
1241+
Value: 'ami-dedicated',
1242+
},
1243+
};
1244+
mockSSMClient.on(GetParameterCommand).resolves(paramValue);
1245+
1246+
await createRunner(createRunnerConfig({
1247+
...dedicatedHostRunnerConfig,
1248+
amiIdSsmParameterName: 'my-ami-id-param',
1249+
}));
1250+
1251+
expect(mockEC2Client).toHaveReceivedCommandWith(RunInstancesCommand, {
1252+
LaunchTemplate: {
1253+
LaunchTemplateName: LAUNCH_TEMPLATE,
1254+
Version: '$Default',
1255+
},
1256+
InstanceType: 'm5.large',
1257+
MinCount: 1,
1258+
MaxCount: 1,
1259+
SubnetId: 'subnet-123',
1260+
ImageId: 'ami-dedicated',
1261+
TagSpecifications: [
1262+
{
1263+
ResourceType: 'instance',
1264+
Tags: [
1265+
{ Key: 'ghr:Application', Value: 'github-action-runner' },
1266+
{ Key: 'ghr:created_by', Value: 'scale-up-lambda' },
1267+
{ Key: 'ghr:Type', Value: 'Org' },
1268+
{ Key: 'ghr:Owner', Value: REPO_NAME },
1269+
],
1270+
},
1271+
{
1272+
ResourceType: 'volume',
1273+
Tags: [
1274+
{ Key: 'ghr:Application', Value: 'github-action-runner' },
1275+
{ Key: 'ghr:created_by', Value: 'scale-up-lambda' },
1276+
{ Key: 'ghr:Type', Value: 'Org' },
1277+
{ Key: 'ghr:Owner', Value: REPO_NAME },
1278+
],
1279+
},
1280+
],
1281+
});
1282+
expect(mockSSMClient).toHaveReceivedCommandWith(GetParameterCommand, {
1283+
Name: 'my-ami-id-param',
1284+
});
1285+
});
1286+
});

lambdas/functions/control-plane/src/aws/runners.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
DeleteTagsCommand,
66
DescribeInstancesCommand,
77
DescribeInstancesResult,
8+
RunInstancesCommand,
89
EC2Client,
910
FleetLaunchTemplateOverridesRequest,
1011
Tag,
@@ -160,6 +161,15 @@ export async function createRunner(runnerParameters: Runners.RunnerInputParamete
160161
const ec2Client = getTracedAWSV3Client(new EC2Client({ region: process.env.AWS_REGION }));
161162
const amiIdOverride = await getAmiIdOverride(runnerParameters);
162163

164+
// EC2 Fleet (CreateFleet) does not support launching instances onto dedicated hosts
165+
// for instance types like mac*.metal. Use RunInstances directly instead.
166+
if (runnerParameters.useDedicatedHost) {
167+
logger.info('Using RunInstances for dedicated host placement (CreateFleet does not support dedicated hosts).');
168+
const instances = await createInstancesWithRunInstances(runnerParameters, amiIdOverride, ec2Client);
169+
logger.info(`Created instance(s) via RunInstances: ${instances.join(',')}`);
170+
return instances;
171+
}
172+
163173
const fleet: CreateFleetResult = await createInstances(runnerParameters, amiIdOverride, ec2Client);
164174

165175
const instances: string[] = await processFleetResult(fleet, runnerParameters);
@@ -297,6 +307,7 @@ async function createInstances(
297307
],
298308
Type: 'instant',
299309
});
310+
logger.debug('CreateFleet request payload.', { payload: createFleetCommand.input });
300311
fleet = await ec2Client.send(createFleetCommand);
301312
} catch (e) {
302313
logger.warn('Create fleet request failed.', { error: e as Error });
@@ -305,6 +316,67 @@ async function createInstances(
305316
return fleet;
306317
}
307318

319+
async function createInstancesWithRunInstances(
320+
runnerParameters: Runners.RunnerInputParameters,
321+
amiIdOverride: string | undefined,
322+
ec2Client: EC2Client,
323+
): Promise<string[]> {
324+
const tags = [
325+
{ Key: 'ghr:Application', Value: 'github-action-runner' },
326+
{ Key: 'ghr:created_by', Value: runnerParameters.numberOfRunners === 1 ? 'scale-up-lambda' : 'pool-lambda' },
327+
{ Key: 'ghr:Type', Value: runnerParameters.runnerType },
328+
{ Key: 'ghr:Owner', Value: runnerParameters.runnerOwner },
329+
];
330+
331+
if (runnerParameters.tracingEnabled) {
332+
const traceId = tracer.getRootXrayTraceId();
333+
tags.push({ Key: 'ghr:trace_id', Value: traceId! });
334+
}
335+
336+
try {
337+
if (runnerParameters.ec2instanceCriteria.targetCapacityType === 'spot') {
338+
throw new Error('Spot instances are not supported with RunInstances. Please set targetCapacityType to on-demand for dedicated hosts.');
339+
}
340+
341+
const instanceType = runnerParameters.ec2instanceCriteria.instanceTypes[0] as _InstanceType;
342+
const runInstancesCommand = new RunInstancesCommand({
343+
LaunchTemplate: {
344+
LaunchTemplateName: runnerParameters.launchTemplateName,
345+
Version: '$Default',
346+
},
347+
InstanceType: instanceType,
348+
MinCount: runnerParameters.numberOfRunners,
349+
MaxCount: runnerParameters.numberOfRunners,
350+
SubnetId: runnerParameters.subnets[0],
351+
...(amiIdOverride ? { ImageId: amiIdOverride } : {}),
352+
TagSpecifications: [
353+
{
354+
ResourceType: 'instance',
355+
Tags: tags,
356+
},
357+
{
358+
ResourceType: 'volume',
359+
Tags: tags,
360+
},
361+
],
362+
});
363+
364+
logger.debug('RunInstances request payload.', { payload: runInstancesCommand.input });
365+
const result = await ec2Client.send(runInstancesCommand);
366+
const instanceIds = result.Instances?.map((i) => i.InstanceId!).filter(Boolean) || [];
367+
368+
if (instanceIds.length === 0) {
369+
throw new Error('RunInstances returned no instances for dedicated host.');
370+
}
371+
372+
return instanceIds;
373+
} catch (e) {
374+
logger.warn('RunInstances request failed for dedicated host.', { error: e as Error });
375+
throw e;
376+
}
377+
}
378+
379+
308380
// If launchTime is undefined, this will return false
309381
export function bootTimeExceeded(ec2Runner: { launchTime?: Date }): boolean {
310382
const runnerBootTimeInMinutes = process.env.RUNNER_BOOT_TIME_IN_MINUTES;

lambdas/functions/control-plane/src/scale-runners/scale-up.test.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,7 @@ const EXPECTED_RUNNER_PARAMS: RunnerInputParameters = {
114114
onDemandFailoverOnError: [],
115115
scaleErrors: ['UnfulfillableCapacity', 'MaxSpotInstanceCountExceeded', 'TargetCapacityLimitExceededException'],
116116
source: 'scale-up-lambda',
117+
useDedicatedHost: false,
117118
};
118119
let expectedRunnerParams: RunnerInputParameters;
119120

@@ -3084,6 +3085,42 @@ describe('parseEc2OverrideConfig', () => {
30843085
});
30853086
});
30863087

3088+
describe('useDedicatedHost', () => {
3089+
beforeEach(() => {
3090+
process.env.ENABLE_ORGANIZATION_RUNNERS = 'true';
3091+
process.env.ENABLE_EPHEMERAL_RUNNERS = 'true';
3092+
process.env.RUNNER_NAME_PREFIX = 'unit-test-';
3093+
process.env.RUNNER_GROUP_NAME = 'Default';
3094+
process.env.SSM_CONFIG_PATH = '/github-action-runners/default/runners/config';
3095+
process.env.SSM_TOKEN_PATH = '/github-action-runners/default/runners/config';
3096+
process.env.RUNNER_LABELS = 'label1,label2';
3097+
});
3098+
3099+
it('defaults to false when USE_DEDICATED_HOST env var is not set', async () => {
3100+
delete process.env.USE_DEDICATED_HOST;
3101+
await scaleUpModule.scaleUp(TEST_DATA);
3102+
expect(createRunner).toHaveBeenCalledWith(
3103+
expect.objectContaining({ useDedicatedHost: false }),
3104+
);
3105+
});
3106+
3107+
it('is true when USE_DEDICATED_HOST is "true"', async () => {
3108+
process.env.USE_DEDICATED_HOST = 'true';
3109+
await scaleUpModule.scaleUp(TEST_DATA);
3110+
expect(createRunner).toHaveBeenCalledWith(
3111+
expect.objectContaining({ useDedicatedHost: true }),
3112+
);
3113+
});
3114+
3115+
it('is false when USE_DEDICATED_HOST is "false"', async () => {
3116+
process.env.USE_DEDICATED_HOST = 'false';
3117+
await scaleUpModule.scaleUp(TEST_DATA);
3118+
expect(createRunner).toHaveBeenCalledWith(
3119+
expect.objectContaining({ useDedicatedHost: false }),
3120+
);
3121+
});
3122+
});
3123+
30873124
function defaultOctokitMockImpl() {
30883125
mockOctokit.actions.getJobForWorkflowRun.mockImplementation(() => ({
30893126
data: {

0 commit comments

Comments
 (0)