@@ -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
709711function 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+ } ) ;
0 commit comments