| // Copyright 2020 The Go Authors. All rights reserved. |
| // Use of this source code is governed by a BSD-style |
| // license that can be found in the LICENSE file. |
| |
| package cloud |
| |
| import ( |
| "context" |
| "encoding/base64" |
| "encoding/json" |
| "errors" |
| "fmt" |
| "log" |
| "time" |
| |
| "github.com/aws/aws-sdk-go/aws" |
| "github.com/aws/aws-sdk-go/aws/credentials" |
| "github.com/aws/aws-sdk-go/aws/request" |
| "github.com/aws/aws-sdk-go/aws/session" |
| "github.com/aws/aws-sdk-go/service/ec2" |
| "github.com/aws/aws-sdk-go/service/servicequotas" |
| ) |
| |
| const ( |
| // tagName denotes the text used for Name tags. |
| tagName = "Name" |
| // tagDescription denotes the text used for Description tags. |
| tagDescription = "Description" |
| ) |
| |
| const ( |
| // QuotaCodeCPUOnDemand is the quota code for on-demand CPUs. |
| QuotaCodeCPUOnDemand = "L-1216C47A" |
| // QuotaServiceEC2 is the service code for the EC2 service. |
| QuotaServiceEC2 = "ec2" |
| ) |
| |
| // vmClient defines the interface used to call the backing EC2 service. This is a partial interface |
| // based on the EC2 package defined at github.com/aws/aws-sdk-go/service/ec2. |
| type vmClient interface { |
| DescribeInstancesPagesWithContext(context.Context, *ec2.DescribeInstancesInput, func(*ec2.DescribeInstancesOutput, bool) bool, ...request.Option) error |
| DescribeInstancesWithContext(context.Context, *ec2.DescribeInstancesInput, ...request.Option) (*ec2.DescribeInstancesOutput, error) |
| RunInstancesWithContext(context.Context, *ec2.RunInstancesInput, ...request.Option) (*ec2.Reservation, error) |
| TerminateInstancesWithContext(context.Context, *ec2.TerminateInstancesInput, ...request.Option) (*ec2.TerminateInstancesOutput, error) |
| WaitUntilInstanceRunningWithContext(context.Context, *ec2.DescribeInstancesInput, ...request.WaiterOption) error |
| DescribeInstanceTypesPagesWithContext(context.Context, *ec2.DescribeInstanceTypesInput, func(*ec2.DescribeInstanceTypesOutput, bool) bool, ...request.Option) error |
| } |
| |
| // quotaClient defines the interface used to call the backing service quotas service. This |
| // is a partial interface based on the service quota package defined at |
| // github.com/aws/aws-sdk-go/service/servicequotas. |
| type quotaClient interface { |
| GetServiceQuota(*servicequotas.GetServiceQuotaInput) (*servicequotas.GetServiceQuotaOutput, error) |
| } |
| |
| // EC2VMConfiguration is the configuration needed for an EC2 instance. |
| type EC2VMConfiguration struct { |
| // Description is a user defined description of the instance. It is displayed |
| // on the AWS UI. It is an optional field. |
| Description string |
| // ImageID is the ID of the image used to launch the instance. It is a required field. |
| ImageID string |
| // Name is a user defined name for the instance. It is displayed on the AWS UI. It is |
| // an optional field. |
| Name string |
| // SSHKeyID is the name of the SSH key pair to use for access. It is a required field. |
| SSHKeyID string |
| // SecurityGroups contains the names of the security groups to be applied to the VM. If none |
| // are provided the default security group will be used. |
| SecurityGroups []string |
| // Tags the tags to apply to the resources during launch. |
| Tags map[string]string |
| // Type is the type of instance. |
| Type string |
| // UserData is the user data to make available to the instance. This data is available |
| // on the VM via the metadata endpoints. It must be a base64-encoded string. User |
| // data is limited to 16 KB. |
| UserData string |
| // Zone the Availability Zone of the instance. |
| Zone string |
| } |
| |
| // Instance is a virtual machine. |
| type Instance struct { |
| // CPUCount is the number of VCPUs the instance is configured with. |
| CPUCount int64 |
| // CreatedAt is the time when the instance was launched. |
| CreatedAt time.Time |
| // Description is a user defined description of the instance. |
| Description string |
| // ID is the instance ID. |
| ID string |
| // IPAddressExternal is the public IPv4 address assigned to the instance. |
| IPAddressExternal string |
| // IPAddressInternal is the private IPv4 address assigned to the instance. |
| IPAddressInternal string |
| // ImageID is The ID of the AMI(image) used to launch the instance. |
| ImageID string |
| // Name is a user defined name for the instance. |
| Name string |
| // SSHKeyID is the name of the SSH key pair to use for access. It is a required field. |
| SSHKeyID string |
| // SecurityGroups is the security groups for the instance. |
| SecurityGroups []string |
| // State contains the state of the instance. |
| State string |
| // Tags contains tags assigned to the instance. |
| Tags map[string]string |
| // Type is the name of instance type. |
| Type string |
| // Zone is the availability zone where the instance is deployed. |
| Zone string |
| } |
| |
| // AWSClient is a client for AWS services. |
| type AWSClient struct { |
| ec2Client vmClient |
| quotaClient quotaClient |
| } |
| |
| // AWSOpt is an optional configuration setting for the AWSClient. |
| type AWSOpt func(*AWSClient) |
| |
| // NewAWSClient creates a new AWS client. |
| func NewAWSClient(region, keyID, accessKey string, opts ...AWSOpt) (*AWSClient, error) { |
| s, err := session.NewSession(&aws.Config{ |
| Region: aws.String(region), |
| Credentials: credentials.NewStaticCredentials(keyID, accessKey, ""), // Token is only required for STS |
| }) |
| if err != nil { |
| return nil, fmt.Errorf("failed to create AWS session: %v", err) |
| } |
| c := &AWSClient{ |
| ec2Client: ec2.New(s), |
| quotaClient: servicequotas.New(s), |
| } |
| for _, opt := range opts { |
| opt(c) |
| } |
| return c, nil |
| } |
| |
| // Instance retrieves an EC2 instance by instance ID. |
| func (ac *AWSClient) Instance(ctx context.Context, instID string) (*Instance, error) { |
| dio, err := ac.ec2Client.DescribeInstancesWithContext(ctx, &ec2.DescribeInstancesInput{ |
| InstanceIds: []*string{aws.String(instID)}, |
| }) |
| if err != nil { |
| return nil, fmt.Errorf("unable to retrieve instance %q information: %w", instID, err) |
| } |
| |
| if dio == nil || len(dio.Reservations) != 1 || len(dio.Reservations[0].Instances) != 1 { |
| return nil, errors.New("describe instances output does not contain a valid instance") |
| } |
| ec2Inst := dio.Reservations[0].Instances[0] |
| return ec2ToInstance(ec2Inst), err |
| } |
| |
| // RunningInstances retrieves all EC2 instances in a region which have not been terminated or stopped. |
| func (ac *AWSClient) RunningInstances(ctx context.Context) ([]*Instance, error) { |
| instances := make([]*Instance, 0) |
| |
| fn := func(page *ec2.DescribeInstancesOutput, lastPage bool) bool { |
| for _, res := range page.Reservations { |
| for _, inst := range res.Instances { |
| instances = append(instances, ec2ToInstance(inst)) |
| } |
| } |
| return true |
| } |
| err := ac.ec2Client.DescribeInstancesPagesWithContext(ctx, &ec2.DescribeInstancesInput{ |
| Filters: []*ec2.Filter{ |
| &ec2.Filter{ |
| Name: aws.String("instance-state-name"), |
| Values: []*string{aws.String(ec2.InstanceStateNameRunning), aws.String(ec2.InstanceStateNamePending)}, |
| }, |
| }, |
| }, fn) |
| if err != nil { |
| return nil, err |
| } |
| return instances, nil |
| } |
| |
| // CreateInstance creates an EC2 VM instance. |
| func (ac *AWSClient) CreateInstance(ctx context.Context, config *EC2VMConfiguration) (*Instance, error) { |
| if config == nil { |
| return nil, errors.New("unable to create a VM with a nil instance") |
| } |
| runResult, err := ac.ec2Client.RunInstancesWithContext(ctx, vmConfig(config)) |
| if err != nil { |
| return nil, fmt.Errorf("unable to create instance: %w", err) |
| } |
| if runResult == nil || len(runResult.Instances) != 1 { |
| return nil, fmt.Errorf("unexpected number of instances. want 1; got %d", len(runResult.Instances)) |
| } |
| return ec2ToInstance(runResult.Instances[0]), nil |
| } |
| |
| // DestroyInstances terminates EC2 VM instances. |
| func (ac *AWSClient) DestroyInstances(ctx context.Context, instIDs ...string) error { |
| ids := aws.StringSlice(instIDs) |
| _, err := ac.ec2Client.TerminateInstancesWithContext(ctx, &ec2.TerminateInstancesInput{ |
| InstanceIds: ids, |
| }) |
| if err != nil { |
| return fmt.Errorf("unable to destroy vm: %w", err) |
| } |
| return err |
| } |
| |
| // WaitUntilInstanceRunning waits until a stopping condition is met. The stopping conditions are: |
| // - The requested instance state is `running`. |
| // - The passed in context is cancelled or the deadline expires. |
| // - 40 requests are made with a 15 second delay between each request. |
| func (ac *AWSClient) WaitUntilInstanceRunning(ctx context.Context, instID string) error { |
| err := ac.ec2Client.WaitUntilInstanceRunningWithContext(ctx, &ec2.DescribeInstancesInput{ |
| InstanceIds: []*string{aws.String(instID)}, |
| }) |
| if err != nil { |
| return fmt.Errorf("failed waiting for vm instance: %w", err) |
| } |
| return err |
| } |
| |
| // InstanceType contains information about an EC2 vm instance type. |
| type InstanceType struct { |
| // Type is the textual label used to describe an instance type. |
| Type string |
| // CPU is the Default vCPU count. |
| CPU int64 |
| } |
| |
| // InstanceTypesARM retrieves all EC2 instance types in a region which support the |
| // ARM64 architecture. |
| func (ac *AWSClient) InstanceTypesARM(ctx context.Context) ([]*InstanceType, error) { |
| var its []*InstanceType |
| contains := func(strs []*string, want string) bool { |
| for _, s := range strs { |
| if aws.StringValue(s) == want { |
| return true |
| } |
| } |
| return false |
| } |
| fn := func(page *ec2.DescribeInstanceTypesOutput, lastPage bool) bool { |
| for _, it := range page.InstanceTypes { |
| if !contains(it.ProcessorInfo.SupportedArchitectures, "arm64") { |
| continue |
| } |
| its = append(its, &InstanceType{ |
| Type: aws.StringValue(it.InstanceType), |
| CPU: aws.Int64Value(it.VCpuInfo.DefaultVCpus), |
| }) |
| } |
| return true |
| } |
| err := ac.ec2Client.DescribeInstanceTypesPagesWithContext(ctx, &ec2.DescribeInstanceTypesInput{}, fn) |
| if err != nil { |
| return nil, fmt.Errorf("failed to retrieve arm64 instance types: %w", err) |
| } |
| return its, nil |
| } |
| |
| // Quota retrieves the requested service quota for the service. |
| func (ac *AWSClient) Quota(ctx context.Context, service, code string) (int64, error) { |
| // TODO(golang.org/issue/36841): use ctx |
| sq, err := ac.quotaClient.GetServiceQuota(&servicequotas.GetServiceQuotaInput{ |
| QuotaCode: aws.String(code), |
| ServiceCode: aws.String(service), |
| }) |
| if err != nil { |
| return 0, fmt.Errorf("failed to retrieve quota: %w", err) |
| } |
| return int64(aws.Float64Value(sq.Quota.Value)), nil |
| } |
| |
| // ec2ToInstance converts an `ec2.Instance` to an `Instance` |
| func ec2ToInstance(inst *ec2.Instance) *Instance { |
| secGroup := make([]string, 0, len(inst.SecurityGroups)) |
| for _, sg := range inst.SecurityGroups { |
| secGroup = append(secGroup, aws.StringValue(sg.GroupId)) |
| } |
| i := &Instance{ |
| CreatedAt: aws.TimeValue(inst.LaunchTime), |
| ID: *inst.InstanceId, |
| IPAddressExternal: aws.StringValue(inst.PublicIpAddress), |
| IPAddressInternal: aws.StringValue(inst.PrivateIpAddress), |
| ImageID: aws.StringValue(inst.ImageId), |
| SSHKeyID: aws.StringValue(inst.KeyName), |
| SecurityGroups: secGroup, |
| State: aws.StringValue(inst.State.Name), |
| Tags: make(map[string]string), |
| Type: aws.StringValue(inst.InstanceType), |
| } |
| if inst.Placement != nil { |
| i.Zone = aws.StringValue(inst.Placement.AvailabilityZone) |
| } |
| if inst.CpuOptions != nil { |
| i.CPUCount = aws.Int64Value(inst.CpuOptions.CoreCount) |
| } |
| for _, tag := range inst.Tags { |
| switch *tag.Key { |
| case tagName: |
| i.Name = *tag.Value |
| case tagDescription: |
| i.Description = *tag.Value |
| default: |
| i.Tags[*tag.Key] = *tag.Value |
| } |
| } |
| return i |
| } |
| |
| // vmConfig converts a configuration into a request to create an instance. |
| func vmConfig(config *EC2VMConfiguration) *ec2.RunInstancesInput { |
| ri := &ec2.RunInstancesInput{ |
| ImageId: aws.String(config.ImageID), |
| InstanceType: aws.String(config.Type), |
| MinCount: aws.Int64(1), |
| MaxCount: aws.Int64(1), |
| Placement: &ec2.Placement{ |
| AvailabilityZone: aws.String(config.Zone), |
| }, |
| KeyName: aws.String(config.SSHKeyID), |
| InstanceInitiatedShutdownBehavior: aws.String(ec2.ShutdownBehaviorTerminate), |
| TagSpecifications: []*ec2.TagSpecification{ |
| &ec2.TagSpecification{ |
| ResourceType: aws.String("instance"), |
| Tags: []*ec2.Tag{ |
| &ec2.Tag{ |
| Key: aws.String(tagName), |
| Value: aws.String(config.Name), |
| }, |
| &ec2.Tag{ |
| Key: aws.String(tagDescription), |
| Value: aws.String(config.Description), |
| }, |
| }, |
| }, |
| }, |
| SecurityGroups: aws.StringSlice(config.SecurityGroups), |
| UserData: aws.String(config.UserData), |
| } |
| for k, v := range config.Tags { |
| ri.TagSpecifications[0].Tags = append(ri.TagSpecifications[0].Tags, &ec2.Tag{ |
| Key: aws.String(k), |
| Value: aws.String(v), |
| }) |
| } |
| return ri |
| } |
| |
| // EC2UserData is stored in the user data for each EC2 instance. This is |
| // used to store metadata about the running instance. The buildlet will retrieve |
| // this on EC2 instances before allowing connections from the coordinator. |
| type EC2UserData struct { |
| // BuildletBinaryURL is the url to the buildlet binary stored on GCS. |
| BuildletBinaryURL string `json:"buildlet_binary_url,omitempty"` |
| // BuildletHostType is the host type used by the buildlet. For example, `host-linux-arm64-aws`. |
| BuildletHostType string `json:"buildlet_host_type,omitempty"` |
| // BuildletImageURL is the url for the buildlet container image. |
| BuildletImageURL string `json:"buildlet_image_url,omitempty"` |
| // BuildletName is the name which should be passed onto the buildlet. |
| BuildletName string `json:"buildlet_name,omitempty"` |
| // Metadata provides a location for arbitrary metadata to be stored. |
| Metadata map[string]string `json:"metadata,omitempty"` |
| // TLSCert is the TLS certificate used by the buildlet. |
| TLSCert string `json:"tls_cert,omitempty"` |
| // TLSKey is the TLS key used by the buildlet. |
| TLSKey string `json:"tls_key,omitempty"` |
| // TLSPassword contains the SHA1 of the TLS key used by the buildlet for basic authentication. |
| TLSPassword string `json:"tls_password,omitempty"` |
| } |
| |
| // EncodedString converts `EC2UserData` into JSON which is base64 encoded. |
| // User data must be base64 encoded upon creation. |
| func (ud *EC2UserData) EncodedString() string { |
| jsonUserData, err := json.Marshal(ud) |
| if err != nil { |
| log.Printf("unable to marshal user data: %v", err) |
| } |
| return base64.StdEncoding.EncodeToString([]byte(jsonUserData)) |
| } |