@@ -20,6 +20,7 @@ import (
2020 "github.com/aws/aws-lambda-go/events"
2121 "github.com/aws/aws-lambda-go/lambda"
2222 "github.com/aws/aws-sdk-go-v2/aws"
23+ "github.com/aws/aws-sdk-go-v2/aws/retry"
2324 "github.com/aws/aws-sdk-go-v2/config"
2425 "github.com/aws/aws-sdk-go-v2/service/ec2"
2526 "github.com/aws/aws-sdk-go-v2/service/ec2/types"
@@ -38,16 +39,129 @@ type RunnerConfiguration struct {
3839 KeyName string `json:"key"`
3940}
4041
41- // retryableLaunchErrors are EC2 error codes for which launching the runner in
42- // the next configured subnet (potentially a different AZ) may succeed.
43- var retryableLaunchErrors = []string {
42+ // launchCycleErrorCodes are EC2 error codes for which launching the runner in
43+ // the next configured subnet (potentially a different AZ) or the next candidate
44+ // instance type may succeed. launchInstance handles these itself by cycling to
45+ // the next subnet/type, so excludeLaunchCycleErrors also marks them
46+ // non-retryable on the client (see there for why).
47+ var launchCycleErrorCodes = []string {
4448 "InsufficientFreeAddressesInSubnet" ,
4549 "InsufficientInstanceCapacity" ,
4650 "InvalidSubnetID.NotFound" ,
4751 "Unsupported" ,
4852}
4953
50- func handler (request events.APIGatewayProxyRequest ) (events.APIGatewayProxyResponse , error ) {
54+ // isLaunchCycleError reports whether err is one launchInstance handles by moving
55+ // on to the next subnet/instance type, rather than a fatal error or a throttle
56+ // the SDK's retryer already backed off and retried.
57+ func isLaunchCycleError (err error ) bool {
58+ apiErr , ok := errors.AsType [smithy.APIError ](err )
59+
60+ return ok && slices .Contains (launchCycleErrorCodes , apiErr .ErrorCode ())
61+ }
62+
63+ // excludeLaunchCycleErrors keeps the SDK retryer from retrying the errors that
64+ // launchInstance handles by cycling to the next subnet/instance type.
65+ // InsufficientInstanceCapacity is an HTTP 500, so the default retryer would
66+ // otherwise burn its own attempts (with backoff) retrying the same subnet before
67+ // launchInstance ever gets to try the next one. Returning FalseTernary here
68+ // short-circuits that so the error surfaces immediately; throttling and other
69+ // transient errors fall through to UnknownTernary and keep default retry.
70+ var excludeLaunchCycleErrors = retry .IsErrorRetryableFunc (func (err error ) aws.Ternary {
71+ if isLaunchCycleError (err ) {
72+ return aws .FalseTernary
73+ }
74+
75+ return aws .UnknownTernary
76+ })
77+
78+ // newEC2Retryer returns the SDK's standard retryer (throttle + transient backoff
79+ // left at defaults) with cycle errors excluded so launchInstance can move to the
80+ // next subnet/type without waiting on same-call retries.
81+ func newEC2Retryer () * retry.Standard {
82+ return retry .NewStandard (func (o * retry.StandardOptions ) {
83+ o .Retryables = append (
84+ []retry.IsErrorRetryable {excludeLaunchCycleErrors },
85+ retry .DefaultRetryables ... ,
86+ )
87+ })
88+ }
89+
90+ // launchInstance attempts to launch a runner across the candidate instance types
91+ // and subnets. It tries each instance type in turn, sweeping every subnet; a
92+ // capacity or subnet error (see retryableLaunchErrors) moves straight on to the
93+ // next subnet, then the next type, so a type that is out of capacity in every AZ
94+ // falls through to the next candidate. Throttling and other transient errors are
95+ // backed off and retried by the EC2 client's own retryer before they reach here,
96+ // so any error that is not a cycle error is treated as fatal. It returns the
97+ // launched instance ID, or an error if no type/subnet combination succeeds.
98+ func launchInstance (
99+ ctx context.Context ,
100+ svc * ec2.Client ,
101+ runInput * ec2.RunInstancesInput ,
102+ instanceTypes []types.InstanceType ,
103+ subnets []string ,
104+ ) (string , error ) {
105+ var lastErr error
106+
107+ for _ , instanceType := range instanceTypes {
108+ runInput .InstanceType = instanceType
109+
110+ for _ , subnet := range subnets {
111+ runInput .NetworkInterfaces [0 ].SubnetId = aws .String (subnet )
112+
113+ output , err := svc .RunInstances (ctx , runInput )
114+ if err != nil {
115+ if ! isLaunchCycleError (err ) {
116+ slog .Error ("failed to run instances" , "error" , err .Error ())
117+
118+ return "" , err
119+ }
120+
121+ slog .Warn (
122+ "capacity/subnet error, trying next" ,
123+ "instanceType" , instanceType ,
124+ "subnet" , subnet ,
125+ "error" , err .Error (),
126+ )
127+
128+ lastErr = err
129+
130+ continue
131+ }
132+
133+ if len (output .Instances ) == 0 || output .Instances [0 ].InstanceId == nil {
134+ slog .Warn (
135+ "no instance created in subnet, trying next" ,
136+ "instanceType" , instanceType ,
137+ "subnet" , subnet ,
138+ )
139+
140+ lastErr = errors .New ("run instances returned no instance id" )
141+
142+ continue
143+ }
144+
145+ return aws .ToString (output .Instances [0 ].InstanceId ), nil
146+ }
147+
148+ slog .Warn (
149+ "all subnets failed for instance type, trying next type" ,
150+ "instanceType" , instanceType ,
151+ )
152+ }
153+
154+ if lastErr == nil {
155+ lastErr = errors .New ("failed to launch instance in any subnet" )
156+ }
157+
158+ return "" , fmt .Errorf ("failed to launch instance: %w" , lastErr )
159+ }
160+
161+ func handler (
162+ ctx context.Context ,
163+ request events.APIGatewayProxyRequest ,
164+ ) (events.APIGatewayProxyResponse , error ) {
51165 var githubEventHeader string
52166
53167 for k , v := range request .MultiValueHeaders {
@@ -103,19 +217,29 @@ func handler(request events.APIGatewayProxyRequest) (events.APIGatewayProxyRespo
103217
104218 region := cmp .Or (os .Getenv ("AWS_DEFAULT_REGION" ), os .Getenv ("AWS_REGION" ))
105219
106- instanceType := types .InstanceTypeC7aLarge
107- instanceTypes := instanceType .Values ()
220+ validInstanceTypes := types .InstanceTypeC7aLarge .Values ()
221+
222+ // Candidate instance types to try, in the order the labels appear on the
223+ // job. Trying several lets the launch fall back when a type is out of
224+ // capacity (InsufficientInstanceCapacity) in every configured subnet.
225+ var instanceTypes []types.InstanceType
108226
109227 for _ , label := range event .GetWorkflowJob ().Labels {
110228 if _ , ok := runnerConfig [label ]; ok {
111229 region = label
112230 }
113231
114- if slices .Contains (instanceTypes , types .InstanceType (label )) {
115- instanceType = types .InstanceType (label )
232+ candidate := types .InstanceType (label )
233+ if slices .Contains (validInstanceTypes , candidate ) &&
234+ ! slices .Contains (instanceTypes , candidate ) {
235+ instanceTypes = append (instanceTypes , candidate )
116236 }
117237 }
118238
239+ if len (instanceTypes ) == 0 {
240+ instanceTypes = []types.InstanceType {types .InstanceTypeC7aLarge }
241+ }
242+
119243 regionCfg , ok := runnerConfig [region ]
120244 if ! ok {
121245 return events.APIGatewayProxyResponse {StatusCode : http .StatusInternalServerError },
@@ -127,14 +251,16 @@ func handler(request events.APIGatewayProxyRequest) (events.APIGatewayProxyRespo
127251 fmt .Errorf ("no subnets configured for region %s" , region )
128252 }
129253
130- cfg , err := config .LoadDefaultConfig (context . TODO () , config .WithRegion (region ))
254+ cfg , err := config .LoadDefaultConfig (ctx , config .WithRegion (region ))
131255 if err != nil {
132256 return events.APIGatewayProxyResponse {StatusCode : http .StatusInternalServerError }, err
133257 }
134258
135259 slog .Info ("creating runner in region" , "region" , region )
136260
137- svc := ec2 .NewFromConfig (cfg )
261+ svc := ec2 .NewFromConfig (cfg , func (o * ec2.Options ) {
262+ o .Retryer = newEC2Retryer ()
263+ })
138264 sm := secretsmanager .NewFromConfig (cfg )
139265
140266 secretName := os .Getenv ("GITHUB_PAT_SECRET_NAME" )
@@ -149,7 +275,7 @@ func handler(request events.APIGatewayProxyRequest) (events.APIGatewayProxyRespo
149275 }
150276
151277 secretOut , err := sm .GetSecretValue (
152- context . TODO () ,
278+ ctx ,
153279 & secretsmanager.GetSecretValueInput {SecretId : aws .String (secretName )},
154280 )
155281 if err != nil {
@@ -194,7 +320,7 @@ func handler(request events.APIGatewayProxyRequest) (events.APIGatewayProxyRespo
194320 return events.APIGatewayProxyResponse {StatusCode : http .StatusOK }, nil
195321 }
196322
197- slog .Info ("creating instance" , "instanceType " , instanceType )
323+ slog .Info ("creating instance" , "instanceTypes " , instanceTypes )
198324
199325 tpl , err := template .New ("userdata" ).Parse (userData )
200326 if err != nil {
@@ -219,7 +345,8 @@ func handler(request events.APIGatewayProxyRequest) (events.APIGatewayProxyRespo
219345 EbsOptimized : aws .Bool (true ),
220346 ImageId : aws .String (regionCfg .ImageID ),
221347 InstanceInitiatedShutdownBehavior : types .ShutdownBehaviorTerminate ,
222- InstanceType : instanceType ,
348+ // InstanceType is set per-attempt by launchInstance so it can fall
349+ // back across the candidate instanceTypes on capacity errors.
223350 IamInstanceProfile : & types.IamInstanceProfileSpecification {
224351 Arn : aws .String (instanceProfileArn ),
225352 },
@@ -247,67 +374,22 @@ func handler(request events.APIGatewayProxyRequest) (events.APIGatewayProxyRespo
247374 UserData : aws .String (base64 .StdEncoding .EncodeToString ([]byte (finalUserData ))),
248375 }
249376
250- var lastErr error
251-
252- for _ , subnet := range regionCfg .SubnetID {
253- runInput .NetworkInterfaces [0 ].SubnetId = aws .String (subnet )
254-
255- output , err := svc .RunInstances (context .TODO (), runInput )
256- if err != nil {
257- apiErr , ok := errors.AsType [smithy.APIError ](err )
258- if ok && slices .Contains (retryableLaunchErrors , apiErr .ErrorCode ()) {
259- slog .Warn (
260- "retrying in next subnet" ,
261- "subnet" ,
262- subnet ,
263- "reason" ,
264- apiErr .ErrorCode (),
265- )
266-
267- lastErr = err
268-
269- continue
270- }
271-
272- slog .Error ("failed to run instances" , "error" , err .Error ())
273-
274- return events.APIGatewayProxyResponse {
275- Body : err .Error (),
276- StatusCode : http .StatusInternalServerError ,
277- }, err
278- }
279-
280- if len (output .Instances ) == 0 || output .Instances [0 ].InstanceId == nil {
281- slog .Warn (
282- "no instance created in subnet, trying next" ,
283- "subnet" ,
284- subnet ,
285- )
286-
287- lastErr = errors .New ("run instances returned no instance id" )
288-
289- continue
290- }
291-
292- instanceID := aws .ToString (output .Instances [0 ].InstanceId )
293- slog .Info ("instance created" , "instanceID" , instanceID )
377+ instanceID , err := launchInstance (ctx , svc , runInput , instanceTypes , regionCfg .SubnetID )
378+ if err != nil {
379+ slog .Error ("failed to launch instance" , "error" , err .Error ())
294380
295381 return events.APIGatewayProxyResponse {
296- Body : instanceID ,
297- StatusCode : http .StatusOK ,
298- }, nil
299- }
300-
301- if lastErr == nil {
302- lastErr = errors .New ("failed to launch instance in any subnet" )
382+ Body : err .Error (),
383+ StatusCode : http .StatusInternalServerError ,
384+ }, err
303385 }
304386
305- slog .Error ( "failed to launch instance in any subnet " , "error " , lastErr . Error () )
387+ slog .Info ( " instance created " , "instanceID " , instanceID )
306388
307389 return events.APIGatewayProxyResponse {
308- Body : lastErr . Error () ,
309- StatusCode : http .StatusInternalServerError ,
310- }, lastErr
390+ Body : instanceID ,
391+ StatusCode : http .StatusOK ,
392+ }, nil
311393
312394 default :
313395 err = fmt .Errorf ("unknown event type %T" , event )
0 commit comments