Skip to content

Commit cfbc37e

Browse files
feat: add input use_dedicated_host
1 parent afd81e2 commit cfbc37e

File tree

14 files changed

+343
-0
lines changed

14 files changed

+343
-0
lines changed

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,4 +46,5 @@ export interface RunnerInputParameters {
4646
tracingEnabled?: boolean;
4747
onDemandFailoverOnError?: string[];
4848
scaleErrors: string[];
49+
useDedicatedHost?: boolean;
4950
}

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';
@@ -704,6 +705,7 @@ interface RunnerConfig {
704705
tracingEnabled?: boolean;
705706
onDemandFailoverOnError?: string[];
706707
scaleErrors: string[];
708+
useDedicatedHost?: boolean;
707709
}
708710

709711
function createRunnerConfig(runnerConfig: RunnerConfig): RunnerInputParameters {
@@ -724,6 +726,7 @@ function createRunnerConfig(runnerConfig: RunnerConfig): RunnerInputParameters {
724726
tracingEnabled: runnerConfig.tracingEnabled,
725727
onDemandFailoverOnError: runnerConfig.onDemandFailoverOnError,
726728
scaleErrors: runnerConfig.scaleErrors,
729+
useDedicatedHost: runnerConfig.useDedicatedHost,
727730
};
728731
}
729732

@@ -811,3 +814,211 @@ function expectedCreateFleetRequest(expectedValues: ExpectedFleetRequestValues):
811814

812815
return request;
813816
}
817+
818+
describe('create runner with useDedicatedHost', () => {
819+
const dedicatedHostRunnerConfig: RunnerConfig = {
820+
allocationStrategy: SpotAllocationStrategy.CAPACITY_OPTIMIZED,
821+
capacityType: 'on-demand',
822+
type: 'Org',
823+
scaleErrors: [],
824+
useDedicatedHost: true,
825+
};
826+
827+
beforeEach(() => {
828+
vi.clearAllMocks();
829+
mockEC2Client.reset();
830+
mockSSMClient.reset();
831+
832+
mockEC2Client.on(RunInstancesCommand).resolves({
833+
Instances: [{ InstanceId: 'i-dedicated-1' }],
834+
});
835+
mockSSMClient.on(GetParameterCommand).resolves({});
836+
});
837+
838+
it('uses RunInstances instead of CreateFleet when useDedicatedHost is true', async () => {
839+
const result = await createRunner(createRunnerConfig(dedicatedHostRunnerConfig));
840+
841+
expect(result).toEqual(['i-dedicated-1']);
842+
expect(mockEC2Client).toHaveReceivedCommand(RunInstancesCommand);
843+
expect(mockEC2Client).not.toHaveReceivedCommand(CreateFleetCommand);
844+
});
845+
846+
it('uses CreateFleet when useDedicatedHost is false', async () => {
847+
mockEC2Client.on(CreateFleetCommand).resolves({ Instances: [{ InstanceIds: ['i-fleet-1'] }] });
848+
849+
const result = await createRunner(createRunnerConfig({
850+
...dedicatedHostRunnerConfig,
851+
useDedicatedHost: false,
852+
}));
853+
854+
expect(result).toEqual(['i-fleet-1']);
855+
expect(mockEC2Client).toHaveReceivedCommand(CreateFleetCommand);
856+
expect(mockEC2Client).not.toHaveReceivedCommand(RunInstancesCommand);
857+
});
858+
859+
it('uses CreateFleet when useDedicatedHost is undefined', async () => {
860+
mockEC2Client.on(CreateFleetCommand).resolves({ Instances: [{ InstanceIds: ['i-fleet-1'] }] });
861+
862+
const result = await createRunner(createRunnerConfig({
863+
...dedicatedHostRunnerConfig,
864+
useDedicatedHost: undefined,
865+
}));
866+
867+
expect(result).toEqual(['i-fleet-1']);
868+
expect(mockEC2Client).toHaveReceivedCommand(CreateFleetCommand);
869+
expect(mockEC2Client).not.toHaveReceivedCommand(RunInstancesCommand);
870+
});
871+
872+
it('passes correct parameters to RunInstances', async () => {
873+
await createRunner(createRunnerConfig(dedicatedHostRunnerConfig));
874+
875+
expect(mockEC2Client).toHaveReceivedCommandWith(RunInstancesCommand, {
876+
LaunchTemplate: {
877+
LaunchTemplateName: LAUNCH_TEMPLATE,
878+
Version: '$Default',
879+
},
880+
InstanceType: 'm5.large',
881+
MinCount: 1,
882+
MaxCount: 1,
883+
SubnetId: 'subnet-123',
884+
TagSpecifications: [
885+
{
886+
ResourceType: 'instance',
887+
Tags: [
888+
{ Key: 'ghr:Application', Value: 'github-action-runner' },
889+
{ Key: 'ghr:created_by', Value: 'scale-up-lambda' },
890+
{ Key: 'ghr:Type', Value: 'Org' },
891+
{ Key: 'ghr:Owner', Value: REPO_NAME },
892+
],
893+
},
894+
{
895+
ResourceType: 'volume',
896+
Tags: [
897+
{ Key: 'ghr:Application', Value: 'github-action-runner' },
898+
{ Key: 'ghr:created_by', Value: 'scale-up-lambda' },
899+
{ Key: 'ghr:Type', Value: 'Org' },
900+
{ Key: 'ghr:Owner', Value: REPO_NAME },
901+
],
902+
},
903+
],
904+
});
905+
});
906+
907+
it('creates multiple instances via RunInstances', async () => {
908+
mockEC2Client.on(RunInstancesCommand).resolves({
909+
Instances: [{ InstanceId: 'i-dedicated-1' }, { InstanceId: 'i-dedicated-2' }],
910+
});
911+
912+
const result = await createRunner({
913+
...createRunnerConfig(dedicatedHostRunnerConfig),
914+
numberOfRunners: 2,
915+
});
916+
917+
expect(result).toEqual(['i-dedicated-1', 'i-dedicated-2']);
918+
expect(mockEC2Client).toHaveReceivedCommandWith(RunInstancesCommand, {
919+
LaunchTemplate: {
920+
LaunchTemplateName: LAUNCH_TEMPLATE,
921+
Version: '$Default',
922+
},
923+
InstanceType: 'm5.large',
924+
MinCount: 2,
925+
MaxCount: 2,
926+
SubnetId: 'subnet-123',
927+
TagSpecifications: [
928+
{
929+
ResourceType: 'instance',
930+
Tags: [
931+
{ Key: 'ghr:Application', Value: 'github-action-runner' },
932+
{ Key: 'ghr:created_by', Value: 'pool-lambda' },
933+
{ Key: 'ghr:Type', Value: 'Org' },
934+
{ Key: 'ghr:Owner', Value: REPO_NAME },
935+
],
936+
},
937+
{
938+
ResourceType: 'volume',
939+
Tags: [
940+
{ Key: 'ghr:Application', Value: 'github-action-runner' },
941+
{ Key: 'ghr:created_by', Value: 'pool-lambda' },
942+
{ Key: 'ghr:Type', Value: 'Org' },
943+
{ Key: 'ghr:Owner', Value: REPO_NAME },
944+
],
945+
},
946+
],
947+
});
948+
});
949+
950+
it('throws error when spot is used with dedicated host', async () => {
951+
await expect(
952+
createRunner(createRunnerConfig({
953+
...dedicatedHostRunnerConfig,
954+
capacityType: 'spot',
955+
})),
956+
).rejects.toThrow('Spot instances are not supported with RunInstances');
957+
expect(mockEC2Client).not.toHaveReceivedCommand(RunInstancesCommand);
958+
});
959+
960+
it('throws error when RunInstances returns no instances', async () => {
961+
mockEC2Client.on(RunInstancesCommand).resolves({ Instances: [] });
962+
963+
await expect(
964+
createRunner(createRunnerConfig(dedicatedHostRunnerConfig)),
965+
).rejects.toThrow('RunInstances returned no instances for dedicated host.');
966+
});
967+
968+
it('throws error when RunInstances fails', async () => {
969+
mockEC2Client.on(RunInstancesCommand).rejects(new Error('EC2 error'));
970+
971+
await expect(
972+
createRunner(createRunnerConfig(dedicatedHostRunnerConfig)),
973+
).rejects.toThrow('EC2 error');
974+
});
975+
976+
it('uses ami id override from ssm parameter', async () => {
977+
const paramValue: GetParameterResult = {
978+
Parameter: {
979+
Value: 'ami-dedicated',
980+
},
981+
};
982+
mockSSMClient.on(GetParameterCommand).resolves(paramValue);
983+
984+
await createRunner(createRunnerConfig({
985+
...dedicatedHostRunnerConfig,
986+
amiIdSsmParameterName: 'my-ami-id-param',
987+
}));
988+
989+
expect(mockEC2Client).toHaveReceivedCommandWith(RunInstancesCommand, {
990+
LaunchTemplate: {
991+
LaunchTemplateName: LAUNCH_TEMPLATE,
992+
Version: '$Default',
993+
},
994+
InstanceType: 'm5.large',
995+
MinCount: 1,
996+
MaxCount: 1,
997+
SubnetId: 'subnet-123',
998+
ImageId: 'ami-dedicated',
999+
TagSpecifications: [
1000+
{
1001+
ResourceType: 'instance',
1002+
Tags: [
1003+
{ Key: 'ghr:Application', Value: 'github-action-runner' },
1004+
{ Key: 'ghr:created_by', Value: 'scale-up-lambda' },
1005+
{ Key: 'ghr:Type', Value: 'Org' },
1006+
{ Key: 'ghr:Owner', Value: REPO_NAME },
1007+
],
1008+
},
1009+
{
1010+
ResourceType: 'volume',
1011+
Tags: [
1012+
{ Key: 'ghr:Application', Value: 'github-action-runner' },
1013+
{ Key: 'ghr:created_by', Value: 'scale-up-lambda' },
1014+
{ Key: 'ghr:Type', Value: 'Org' },
1015+
{ Key: 'ghr:Owner', Value: REPO_NAME },
1016+
],
1017+
},
1018+
],
1019+
});
1020+
expect(mockSSMClient).toHaveReceivedCommandWith(GetParameterCommand, {
1021+
Name: 'my-ami-id-param',
1022+
});
1023+
});
1024+
});

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,
@@ -152,6 +153,15 @@ export async function createRunner(runnerParameters: Runners.RunnerInputParamete
152153
const ec2Client = getTracedAWSV3Client(new EC2Client({ region: process.env.AWS_REGION }));
153154
const amiIdOverride = await getAmiIdOverride(runnerParameters);
154155

156+
// EC2 Fleet (CreateFleet) does not support launching instances onto dedicated hosts
157+
// for instance types like mac*.metal. Use RunInstances directly instead.
158+
if (runnerParameters.useDedicatedHost) {
159+
logger.info('Using RunInstances for dedicated host placement (CreateFleet does not support dedicated hosts).');
160+
const instances = await createInstancesWithRunInstances(runnerParameters, amiIdOverride, ec2Client);
161+
logger.info(`Created instance(s) via RunInstances: ${instances.join(',')}`);
162+
return instances;
163+
}
164+
155165
const fleet: CreateFleetResult = await createInstances(runnerParameters, amiIdOverride, ec2Client);
156166

157167
const instances: string[] = await processFleetResult(fleet, runnerParameters);
@@ -288,6 +298,7 @@ async function createInstances(
288298
],
289299
Type: 'instant',
290300
});
301+
logger.debug('CreateFleet request payload.', { payload: createFleetCommand.input });
291302
fleet = await ec2Client.send(createFleetCommand);
292303
} catch (e) {
293304
logger.warn('Create fleet request failed.', { error: e as Error });
@@ -296,6 +307,67 @@ async function createInstances(
296307
return fleet;
297308
}
298309

310+
async function createInstancesWithRunInstances(
311+
runnerParameters: Runners.RunnerInputParameters,
312+
amiIdOverride: string | undefined,
313+
ec2Client: EC2Client,
314+
): Promise<string[]> {
315+
const tags = [
316+
{ Key: 'ghr:Application', Value: 'github-action-runner' },
317+
{ Key: 'ghr:created_by', Value: runnerParameters.numberOfRunners === 1 ? 'scale-up-lambda' : 'pool-lambda' },
318+
{ Key: 'ghr:Type', Value: runnerParameters.runnerType },
319+
{ Key: 'ghr:Owner', Value: runnerParameters.runnerOwner },
320+
];
321+
322+
if (runnerParameters.tracingEnabled) {
323+
const traceId = tracer.getRootXrayTraceId();
324+
tags.push({ Key: 'ghr:trace_id', Value: traceId! });
325+
}
326+
327+
try {
328+
if (runnerParameters.ec2instanceCriteria.targetCapacityType === 'spot') {
329+
throw new Error('Spot instances are not supported with RunInstances. Please set targetCapacityType to on-demand for dedicated hosts.');
330+
}
331+
332+
const instanceType = runnerParameters.ec2instanceCriteria.instanceTypes[0] as _InstanceType;
333+
const runInstancesCommand = new RunInstancesCommand({
334+
LaunchTemplate: {
335+
LaunchTemplateName: runnerParameters.launchTemplateName,
336+
Version: '$Default',
337+
},
338+
InstanceType: instanceType,
339+
MinCount: runnerParameters.numberOfRunners,
340+
MaxCount: runnerParameters.numberOfRunners,
341+
SubnetId: runnerParameters.subnets[0],
342+
...(amiIdOverride ? { ImageId: amiIdOverride } : {}),
343+
TagSpecifications: [
344+
{
345+
ResourceType: 'instance',
346+
Tags: tags,
347+
},
348+
{
349+
ResourceType: 'volume',
350+
Tags: tags,
351+
},
352+
],
353+
});
354+
355+
logger.debug('RunInstances request payload.', { payload: runInstancesCommand.input });
356+
const result = await ec2Client.send(runInstancesCommand);
357+
const instanceIds = result.Instances?.map((i) => i.InstanceId!).filter(Boolean) || [];
358+
359+
if (instanceIds.length === 0) {
360+
throw new Error('RunInstances returned no instances for dedicated host.');
361+
}
362+
363+
return instanceIds;
364+
} catch (e) {
365+
logger.warn('RunInstances request failed for dedicated host.', { error: e as Error });
366+
throw e;
367+
}
368+
}
369+
370+
299371
// If launchTime is undefined, this will return false
300372
export function bootTimeExceeded(ec2Runner: { launchTime?: Date }): boolean {
301373
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
@@ -113,6 +113,7 @@ const EXPECTED_RUNNER_PARAMS: RunnerInputParameters = {
113113
tracingEnabled: false,
114114
onDemandFailoverOnError: [],
115115
scaleErrors: ['UnfulfillableCapacity', 'MaxSpotInstanceCountExceeded', 'TargetCapacityLimitExceededException'],
116+
useDedicatedHost: false,
116117
};
117118
let expectedRunnerParams: RunnerInputParameters;
118119

@@ -2018,6 +2019,42 @@ describe('Retry mechanism tests', () => {
20182019
});
20192020
});
20202021

2022+
describe('useDedicatedHost', () => {
2023+
beforeEach(() => {
2024+
process.env.ENABLE_ORGANIZATION_RUNNERS = 'true';
2025+
process.env.ENABLE_EPHEMERAL_RUNNERS = 'true';
2026+
process.env.RUNNER_NAME_PREFIX = 'unit-test-';
2027+
process.env.RUNNER_GROUP_NAME = 'Default';
2028+
process.env.SSM_CONFIG_PATH = '/github-action-runners/default/runners/config';
2029+
process.env.SSM_TOKEN_PATH = '/github-action-runners/default/runners/config';
2030+
process.env.RUNNER_LABELS = 'label1,label2';
2031+
});
2032+
2033+
it('defaults to false when USE_DEDICATED_HOST env var is not set', async () => {
2034+
delete process.env.USE_DEDICATED_HOST;
2035+
await scaleUpModule.scaleUp(TEST_DATA);
2036+
expect(createRunner).toHaveBeenCalledWith(
2037+
expect.objectContaining({ useDedicatedHost: false }),
2038+
);
2039+
});
2040+
2041+
it('is true when USE_DEDICATED_HOST is "true"', async () => {
2042+
process.env.USE_DEDICATED_HOST = 'true';
2043+
await scaleUpModule.scaleUp(TEST_DATA);
2044+
expect(createRunner).toHaveBeenCalledWith(
2045+
expect.objectContaining({ useDedicatedHost: true }),
2046+
);
2047+
});
2048+
2049+
it('is false when USE_DEDICATED_HOST is "false"', async () => {
2050+
process.env.USE_DEDICATED_HOST = 'false';
2051+
await scaleUpModule.scaleUp(TEST_DATA);
2052+
expect(createRunner).toHaveBeenCalledWith(
2053+
expect.objectContaining({ useDedicatedHost: false }),
2054+
);
2055+
});
2056+
});
2057+
20212058
function defaultOctokitMockImpl() {
20222059
mockOctokit.actions.getJobForWorkflowRun.mockImplementation(() => ({
20232060
data: {

0 commit comments

Comments
 (0)