golang.org/x/build@v0.0.0-20240506185731-218518f32b70/internal/cloud/aws.go (about) 1 // Copyright 2020 The Go Authors. All rights reserved. 2 // Use of this source code is governed by a BSD-style 3 // license that can be found in the LICENSE file. 4 5 package cloud 6 7 import ( 8 "context" 9 "encoding/base64" 10 "encoding/json" 11 "errors" 12 "fmt" 13 "log" 14 "time" 15 16 "github.com/aws/aws-sdk-go/aws" 17 "github.com/aws/aws-sdk-go/aws/credentials" 18 "github.com/aws/aws-sdk-go/aws/request" 19 "github.com/aws/aws-sdk-go/aws/session" 20 "github.com/aws/aws-sdk-go/service/ec2" 21 "github.com/aws/aws-sdk-go/service/servicequotas" 22 ) 23 24 const ( 25 // tagName denotes the text used for Name tags. 26 tagName = "Name" 27 // tagDescription denotes the text used for Description tags. 28 tagDescription = "Description" 29 ) 30 31 const ( 32 // QuotaCodeCPUOnDemand is the quota code for on-demand CPUs. 33 QuotaCodeCPUOnDemand = "L-1216C47A" 34 // QuotaServiceEC2 is the service code for the EC2 service. 35 QuotaServiceEC2 = "ec2" 36 ) 37 38 // vmClient defines the interface used to call the backing EC2 service. This is a partial interface 39 // based on the EC2 package defined at github.com/aws/aws-sdk-go/service/ec2. 40 type vmClient interface { 41 DescribeInstancesPagesWithContext(context.Context, *ec2.DescribeInstancesInput, func(*ec2.DescribeInstancesOutput, bool) bool, ...request.Option) error 42 DescribeInstancesWithContext(context.Context, *ec2.DescribeInstancesInput, ...request.Option) (*ec2.DescribeInstancesOutput, error) 43 RunInstancesWithContext(context.Context, *ec2.RunInstancesInput, ...request.Option) (*ec2.Reservation, error) 44 TerminateInstancesWithContext(context.Context, *ec2.TerminateInstancesInput, ...request.Option) (*ec2.TerminateInstancesOutput, error) 45 WaitUntilInstanceRunningWithContext(context.Context, *ec2.DescribeInstancesInput, ...request.WaiterOption) error 46 DescribeInstanceTypesPagesWithContext(context.Context, *ec2.DescribeInstanceTypesInput, func(*ec2.DescribeInstanceTypesOutput, bool) bool, ...request.Option) error 47 } 48 49 // quotaClient defines the interface used to call the backing service quotas service. This 50 // is a partial interface based on the service quota package defined at 51 // github.com/aws/aws-sdk-go/service/servicequotas. 52 type quotaClient interface { 53 GetServiceQuota(*servicequotas.GetServiceQuotaInput) (*servicequotas.GetServiceQuotaOutput, error) 54 } 55 56 // EC2VMConfiguration is the configuration needed for an EC2 instance. 57 type EC2VMConfiguration struct { 58 // Description is a user defined description of the instance. It is displayed 59 // on the AWS UI. It is an optional field. 60 Description string 61 // ImageID is the ID of the image used to launch the instance. It is a required field. 62 ImageID string 63 // Name is a user defined name for the instance. It is displayed on the AWS UI. It is 64 // an optional field. 65 Name string 66 // SSHKeyID is the name of the SSH key pair to use for access. It is a required field. 67 SSHKeyID string 68 // SecurityGroups contains the names of the security groups to be applied to the VM. If none 69 // are provided the default security group will be used. 70 SecurityGroups []string 71 // Tags the tags to apply to the resources during launch. 72 Tags map[string]string 73 // Type is the type of instance. 74 Type string 75 // UserData is the user data to make available to the instance. This data is available 76 // on the VM via the metadata endpoints. It must be a base64-encoded string. User 77 // data is limited to 16 KB. 78 UserData string 79 // Zone the Availability Zone of the instance. 80 Zone string 81 } 82 83 // Instance is a virtual machine. 84 type Instance struct { 85 // CPUCount is the number of VCPUs the instance is configured with. 86 CPUCount int64 87 // CreatedAt is the time when the instance was launched. 88 CreatedAt time.Time 89 // Description is a user defined description of the instance. 90 Description string 91 // ID is the instance ID. 92 ID string 93 // IPAddressExternal is the public IPv4 address assigned to the instance. 94 IPAddressExternal string 95 // IPAddressInternal is the private IPv4 address assigned to the instance. 96 IPAddressInternal string 97 // ImageID is The ID of the AMI(image) used to launch the instance. 98 ImageID string 99 // Name is a user defined name for the instance. 100 Name string 101 // SSHKeyID is the name of the SSH key pair to use for access. It is a required field. 102 SSHKeyID string 103 // SecurityGroups is the security groups for the instance. 104 SecurityGroups []string 105 // State contains the state of the instance. 106 State string 107 // Tags contains tags assigned to the instance. 108 Tags map[string]string 109 // Type is the name of instance type. 110 Type string 111 // Zone is the availability zone where the instance is deployed. 112 Zone string 113 } 114 115 // AWSClient is a client for AWS services. 116 type AWSClient struct { 117 ec2Client vmClient 118 quotaClient quotaClient 119 } 120 121 // AWSOpt is an optional configuration setting for the AWSClient. 122 type AWSOpt func(*AWSClient) 123 124 // NewAWSClient creates a new AWS client. 125 func NewAWSClient(region, keyID, accessKey string, opts ...AWSOpt) (*AWSClient, error) { 126 s, err := session.NewSession(&aws.Config{ 127 Region: aws.String(region), 128 Credentials: credentials.NewStaticCredentials(keyID, accessKey, ""), // Token is only required for STS 129 }) 130 if err != nil { 131 return nil, fmt.Errorf("failed to create AWS session: %v", err) 132 } 133 c := &AWSClient{ 134 ec2Client: ec2.New(s), 135 quotaClient: servicequotas.New(s), 136 } 137 for _, opt := range opts { 138 opt(c) 139 } 140 return c, nil 141 } 142 143 // Instance retrieves an EC2 instance by instance ID. 144 func (ac *AWSClient) Instance(ctx context.Context, instID string) (*Instance, error) { 145 dio, err := ac.ec2Client.DescribeInstancesWithContext(ctx, &ec2.DescribeInstancesInput{ 146 InstanceIds: []*string{aws.String(instID)}, 147 }) 148 if err != nil { 149 return nil, fmt.Errorf("unable to retrieve instance %q information: %w", instID, err) 150 } 151 152 if dio == nil || len(dio.Reservations) != 1 || len(dio.Reservations[0].Instances) != 1 { 153 return nil, errors.New("describe instances output does not contain a valid instance") 154 } 155 ec2Inst := dio.Reservations[0].Instances[0] 156 return ec2ToInstance(ec2Inst), err 157 } 158 159 // RunningInstances retrieves all EC2 instances in a region which have not been terminated or stopped. 160 func (ac *AWSClient) RunningInstances(ctx context.Context) ([]*Instance, error) { 161 instances := make([]*Instance, 0) 162 163 fn := func(page *ec2.DescribeInstancesOutput, lastPage bool) bool { 164 for _, res := range page.Reservations { 165 for _, inst := range res.Instances { 166 instances = append(instances, ec2ToInstance(inst)) 167 } 168 } 169 return true 170 } 171 err := ac.ec2Client.DescribeInstancesPagesWithContext(ctx, &ec2.DescribeInstancesInput{ 172 Filters: []*ec2.Filter{ 173 &ec2.Filter{ 174 Name: aws.String("instance-state-name"), 175 Values: []*string{aws.String(ec2.InstanceStateNameRunning), aws.String(ec2.InstanceStateNamePending)}, 176 }, 177 }, 178 }, fn) 179 if err != nil { 180 return nil, err 181 } 182 return instances, nil 183 } 184 185 // CreateInstance creates an EC2 VM instance. 186 func (ac *AWSClient) CreateInstance(ctx context.Context, config *EC2VMConfiguration) (*Instance, error) { 187 if config == nil { 188 return nil, errors.New("unable to create a VM with a nil instance") 189 } 190 runResult, err := ac.ec2Client.RunInstancesWithContext(ctx, vmConfig(config)) 191 if err != nil { 192 return nil, fmt.Errorf("unable to create instance: %w", err) 193 } 194 if runResult == nil || len(runResult.Instances) != 1 { 195 return nil, fmt.Errorf("unexpected number of instances. want 1; got %d", len(runResult.Instances)) 196 } 197 return ec2ToInstance(runResult.Instances[0]), nil 198 } 199 200 // DestroyInstances terminates EC2 VM instances. 201 func (ac *AWSClient) DestroyInstances(ctx context.Context, instIDs ...string) error { 202 ids := aws.StringSlice(instIDs) 203 _, err := ac.ec2Client.TerminateInstancesWithContext(ctx, &ec2.TerminateInstancesInput{ 204 InstanceIds: ids, 205 }) 206 if err != nil { 207 return fmt.Errorf("unable to destroy vm: %w", err) 208 } 209 return err 210 } 211 212 // WaitUntilInstanceRunning waits until a stopping condition is met. The stopping conditions are: 213 // - The requested instance state is `running`. 214 // - The passed in context is cancelled or the deadline expires. 215 // - 40 requests are made with a 15 second delay between each request. 216 func (ac *AWSClient) WaitUntilInstanceRunning(ctx context.Context, instID string) error { 217 err := ac.ec2Client.WaitUntilInstanceRunningWithContext(ctx, &ec2.DescribeInstancesInput{ 218 InstanceIds: []*string{aws.String(instID)}, 219 }) 220 if err != nil { 221 return fmt.Errorf("failed waiting for vm instance: %w", err) 222 } 223 return err 224 } 225 226 // InstanceType contains information about an EC2 vm instance type. 227 type InstanceType struct { 228 // Type is the textual label used to describe an instance type. 229 Type string 230 // CPU is the Default vCPU count. 231 CPU int64 232 } 233 234 // InstanceTypesARM retrieves all EC2 instance types in a region which support the 235 // ARM64 architecture. 236 func (ac *AWSClient) InstanceTypesARM(ctx context.Context) ([]*InstanceType, error) { 237 var its []*InstanceType 238 contains := func(strs []*string, want string) bool { 239 for _, s := range strs { 240 if aws.StringValue(s) == want { 241 return true 242 } 243 } 244 return false 245 } 246 fn := func(page *ec2.DescribeInstanceTypesOutput, lastPage bool) bool { 247 for _, it := range page.InstanceTypes { 248 if !contains(it.ProcessorInfo.SupportedArchitectures, "arm64") { 249 continue 250 } 251 its = append(its, &InstanceType{ 252 Type: aws.StringValue(it.InstanceType), 253 CPU: aws.Int64Value(it.VCpuInfo.DefaultVCpus), 254 }) 255 } 256 return true 257 } 258 err := ac.ec2Client.DescribeInstanceTypesPagesWithContext(ctx, &ec2.DescribeInstanceTypesInput{}, fn) 259 if err != nil { 260 return nil, fmt.Errorf("failed to retrieve arm64 instance types: %w", err) 261 } 262 return its, nil 263 } 264 265 // Quota retrieves the requested service quota for the service. 266 func (ac *AWSClient) Quota(ctx context.Context, service, code string) (int64, error) { 267 // TODO(golang.org/issue/36841): use ctx 268 sq, err := ac.quotaClient.GetServiceQuota(&servicequotas.GetServiceQuotaInput{ 269 QuotaCode: aws.String(code), 270 ServiceCode: aws.String(service), 271 }) 272 if err != nil { 273 return 0, fmt.Errorf("failed to retrieve quota: %w", err) 274 } 275 return int64(aws.Float64Value(sq.Quota.Value)), nil 276 } 277 278 // ec2ToInstance converts an `ec2.Instance` to an `Instance` 279 func ec2ToInstance(inst *ec2.Instance) *Instance { 280 secGroup := make([]string, 0, len(inst.SecurityGroups)) 281 for _, sg := range inst.SecurityGroups { 282 secGroup = append(secGroup, aws.StringValue(sg.GroupId)) 283 } 284 i := &Instance{ 285 CreatedAt: aws.TimeValue(inst.LaunchTime), 286 ID: *inst.InstanceId, 287 IPAddressExternal: aws.StringValue(inst.PublicIpAddress), 288 IPAddressInternal: aws.StringValue(inst.PrivateIpAddress), 289 ImageID: aws.StringValue(inst.ImageId), 290 SSHKeyID: aws.StringValue(inst.KeyName), 291 SecurityGroups: secGroup, 292 State: aws.StringValue(inst.State.Name), 293 Tags: make(map[string]string), 294 Type: aws.StringValue(inst.InstanceType), 295 } 296 if inst.Placement != nil { 297 i.Zone = aws.StringValue(inst.Placement.AvailabilityZone) 298 } 299 if inst.CpuOptions != nil { 300 i.CPUCount = aws.Int64Value(inst.CpuOptions.CoreCount) 301 } 302 for _, tag := range inst.Tags { 303 switch *tag.Key { 304 case tagName: 305 i.Name = *tag.Value 306 case tagDescription: 307 i.Description = *tag.Value 308 default: 309 i.Tags[*tag.Key] = *tag.Value 310 } 311 } 312 return i 313 } 314 315 // vmConfig converts a configuration into a request to create an instance. 316 func vmConfig(config *EC2VMConfiguration) *ec2.RunInstancesInput { 317 ri := &ec2.RunInstancesInput{ 318 ImageId: aws.String(config.ImageID), 319 InstanceType: aws.String(config.Type), 320 MinCount: aws.Int64(1), 321 MaxCount: aws.Int64(1), 322 Placement: &ec2.Placement{ 323 AvailabilityZone: aws.String(config.Zone), 324 }, 325 KeyName: aws.String(config.SSHKeyID), 326 InstanceInitiatedShutdownBehavior: aws.String(ec2.ShutdownBehaviorTerminate), 327 TagSpecifications: []*ec2.TagSpecification{ 328 &ec2.TagSpecification{ 329 ResourceType: aws.String("instance"), 330 Tags: []*ec2.Tag{ 331 &ec2.Tag{ 332 Key: aws.String(tagName), 333 Value: aws.String(config.Name), 334 }, 335 &ec2.Tag{ 336 Key: aws.String(tagDescription), 337 Value: aws.String(config.Description), 338 }, 339 }, 340 }, 341 }, 342 SecurityGroups: aws.StringSlice(config.SecurityGroups), 343 UserData: aws.String(config.UserData), 344 } 345 for k, v := range config.Tags { 346 ri.TagSpecifications[0].Tags = append(ri.TagSpecifications[0].Tags, &ec2.Tag{ 347 Key: aws.String(k), 348 Value: aws.String(v), 349 }) 350 } 351 return ri 352 } 353 354 // EC2UserData is stored in the user data for each EC2 instance. This is 355 // used to store metadata about the running instance. The buildlet will retrieve 356 // this on EC2 instances before allowing connections from the coordinator. 357 type EC2UserData struct { 358 // BuildletBinaryURL is the url to the buildlet binary stored on GCS. 359 BuildletBinaryURL string `json:"buildlet_binary_url,omitempty"` 360 // BuildletHostType is the host type used by the buildlet. For example, `host-linux-arm64-aws`. 361 BuildletHostType string `json:"buildlet_host_type,omitempty"` 362 // BuildletImageURL is the url for the buildlet container image. 363 BuildletImageURL string `json:"buildlet_image_url,omitempty"` 364 // BuildletName is the name which should be passed onto the buildlet. 365 BuildletName string `json:"buildlet_name,omitempty"` 366 // Metadata provides a location for arbitrary metadata to be stored. 367 Metadata map[string]string `json:"metadata,omitempty"` 368 // TLSCert is the TLS certificate used by the buildlet. 369 TLSCert string `json:"tls_cert,omitempty"` 370 // TLSKey is the TLS key used by the buildlet. 371 TLSKey string `json:"tls_key,omitempty"` 372 // TLSPassword contains the SHA1 of the TLS key used by the buildlet for basic authentication. 373 TLSPassword string `json:"tls_password,omitempty"` 374 } 375 376 // EncodedString converts `EC2UserData` into JSON which is base64 encoded. 377 // User data must be base64 encoded upon creation. 378 func (ud *EC2UserData) EncodedString() string { 379 jsonUserData, err := json.Marshal(ud) 380 if err != nil { 381 log.Printf("unable to marshal user data: %v", err) 382 } 383 return base64.StdEncoding.EncodeToString([]byte(jsonUserData)) 384 }