diff --git a/go.mod b/go.mod index f4c691c9a7..f65abf53ea 100644 --- a/go.mod +++ b/go.mod @@ -28,14 +28,20 @@ replace ( require ( github.com/aws/aws-sdk-go v1.28.2 + github.com/golang/mock v1.3.1 + github.com/google/go-cmp v0.4.0 + github.com/google/uuid v1.1.1 github.com/spf13/cobra v1.0.0 github.com/spf13/pflag v1.0.5 github.com/stretchr/testify v1.4.0 + gopkg.in/gcfg.v1 v1.2.0 + k8s.io/api v0.0.0 k8s.io/apimachinery v0.0.0 k8s.io/apiserver v0.0.0 k8s.io/cloud-provider v0.0.0 k8s.io/component-base v0.0.0 k8s.io/klog v1.0.0 + k8s.io/klog/v2 v2.2.0 k8s.io/kubernetes v1.19.1 k8s.io/legacy-cloud-providers v0.0.0 k8s.io/utils v0.0.0-20200729134348-d5654de09c73 diff --git a/go.sum b/go.sum index 1574a9fe55..1afa2e66a1 100644 --- a/go.sum +++ b/go.sum @@ -262,7 +262,9 @@ github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7 h1:5ZkaAPbicIKTF github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/mock v1.3.1 h1:qGJ6qTW+x6xX/my+8YUVl4WNpX9B7+/l2tRsHGZ7f2s= github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y= +github.com/golang/mock v1.4.4 h1:l75CXGRSwbaYNpl/Z2X1XIIAMSCquvXgpVZDhwEIJsc= github.com/golang/protobuf v1.2.0 h1:P3YflyNX/ehuJFLhxviNdFxQPkGK5cDcApsge1SqnvM= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.1 h1:YF8+flBXS5eO826T4nzqPrxfhQThhXl0YzfuUPu4SBg= @@ -887,6 +889,7 @@ k8s.io/kube-openapi v0.0.0-20200805222855-6aeccd4b50c6 h1:+WnxoVtG8TMiudHBSEtrVL k8s.io/kube-openapi v0.0.0-20200805222855-6aeccd4b50c6/go.mod h1:UuqjUnNftUyPE5H64/qeyjQoUZhGpeFDVdxjTeEVN2o= k8s.io/kubernetes v1.19.1 h1:3Gdl9EtBiV3SYuzml1915nFLVxlx08L6bRz1b07C60k= k8s.io/kubernetes v1.19.1/go.mod h1:yhT1/ltQajQsha3tnYc9QPFYSumGM45nlZdjf7WqE1A= +k8s.io/kubernetes v1.19.2 h1:sEvBYVM1/H5hqejFR10u8ndreYARV3DiTrqi2AY31ok= k8s.io/kubernetes/staging/src/k8s.io/api v0.0.0-20200909111720-206bcadf021e h1:mdkfNp9Iu1erEZy/9kefsctTTyey3aAMcCQ5VTzScIE= k8s.io/kubernetes/staging/src/k8s.io/api v0.0.0-20200909111720-206bcadf021e/go.mod h1:Y4VjjNur38HL6/QxaTVK2yno1zjEQlvcvwbbRQs2DtQ= k8s.io/kubernetes/staging/src/k8s.io/apiextensions-apiserver v0.0.0-20200909111720-206bcadf021e/go.mod h1:BvtZU215FgO19Oy19K6h8qwajFfjxYqGewgjuYHWGRw= diff --git a/pkg/providers/v2/cloud.go b/pkg/providers/v2/cloud.go index 973190f57b..a07524b9c5 100644 --- a/pkg/providers/v2/cloud.go +++ b/pkg/providers/v2/cloud.go @@ -28,6 +28,8 @@ import ( "github.com/aws/aws-sdk-go/aws/credentials/ec2rolecreds" "github.com/aws/aws-sdk-go/aws/ec2metadata" "github.com/aws/aws-sdk-go/aws/session" + "github.com/aws/aws-sdk-go/service/ec2" + cloudprovider "k8s.io/cloud-provider" ) @@ -46,8 +48,11 @@ var _ cloudprovider.Interface = (*cloud)(nil) // cloud is the AWS v2 implementation of the cloud provider interface type cloud struct { - creds *credentials.Credentials - region string + creds *credentials.Credentials + instances cloudprovider.InstancesV2 + region string + ec2 EC2 + metadata EC2Metadata } // EC2Metadata is an abstraction over the AWS metadata service. @@ -107,10 +112,33 @@ func newCloud() (cloudprovider.Interface, error) { return nil, err } - return &cloud{ - creds: creds, - region: region, - }, nil + instances, err := newInstances(az, creds) + if err != nil { + return nil, err + } + + ec2Sess, err := session.NewSession(&aws.Config{ + Region: aws.String(region), + Credentials: creds, + }) + if err != nil { + return nil, fmt.Errorf("unable to initialize AWS session: %v", err) + } + + ec2Service := ec2.New(ec2Sess) + if err != nil { + return nil, fmt.Errorf("error creating AWS ec2 client: %q", err) + } + + awsCloud := &cloud{ + creds: creds, + instances: instances, + region: region, + metadata: metadataClient, + ec2: ec2Service, + } + + return awsCloud, nil } // Initialize passes a Kubernetes clientBuilder interface to the cloud provider @@ -158,5 +186,5 @@ func (c *cloud) HasClusterID() bool { // Also returns true if the interface is supported, false otherwise. // WARNING: InstancesV2 is an experimental interface and is subject to change in v1.20. func (c *cloud) InstancesV2() (cloudprovider.InstancesV2, bool) { - return nil, false + return c.instances, true } diff --git a/pkg/providers/v2/instances.go b/pkg/providers/v2/instances.go new file mode 100644 index 0000000000..879fd1f15a --- /dev/null +++ b/pkg/providers/v2/instances.go @@ -0,0 +1,281 @@ +/* +Copyright 2020 The Kubernetes Authors. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package v2 is an out-of-tree only implementation of the AWS cloud provider. +// It is not compatible with v1 and should only be used on new clusters. +package v2 + +import ( + "context" + "errors" + "fmt" + "net" + "strings" + + "k8s.io/klog/v2" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/aws/credentials" + "github.com/aws/aws-sdk-go/aws/session" + "github.com/aws/aws-sdk-go/service/ec2" + + v1 "k8s.io/api/core/v1" + cloudprovider "k8s.io/cloud-provider" +) + +// newInstances returns an implementation of cloudprovider.InstancesV2 +func newInstances(az string, creds *credentials.Credentials) (cloudprovider.InstancesV2, error) { + region, err := azToRegion(az) + if err != nil { + return nil, err + } + + awsConfig := &aws.Config{ + Region: aws.String(region), + Credentials: creds, + } + awsConfig = awsConfig.WithCredentialsChainVerboseErrors(true) + + sess, err := session.NewSession(awsConfig) + if err != nil { + return nil, fmt.Errorf("error creating new session: %v", err) + } + ec2Service := ec2.New(sess) + + return &instances{ + availabilityZone: az, + ec2: ec2Service, + region: region, + }, nil +} + +// EC2 is an interface defining only the methods we call from the AWS EC2 SDK. +type EC2 interface { + DescribeInstances(request *ec2.DescribeInstancesInput) (*ec2.DescribeInstancesOutput, error) +} + +// instances is an implementation of cloudprovider.InstancesV2 +type instances struct { + availabilityZone string + ec2 EC2 + region string +} + +// InstanceExists indicates whether a given node exists according to the cloud provider +func (i *instances) InstanceExists(ctx context.Context, node *v1.Node) (bool, error) { + _, err := i.getInstance(ctx, node) + + if err == cloudprovider.InstanceNotFound { + klog.V(6).Infof("instance not found for node: %s", node.Name) + return false, nil + } + + if err != nil { + return false, err + } + + return true, nil +} + +// InstanceShutdown returns true if the instance is shutdown according to the cloud provider. +func (i *instances) InstanceShutdown(ctx context.Context, node *v1.Node) (bool, error) { + ec2Instance, err := i.getInstance(ctx, node) + if err != nil { + return false, err + } + + if ec2Instance.State != nil { + state := aws.StringValue(ec2Instance.State.Name) + // valid state for detaching volumes + if state == ec2.InstanceStateNameStopped { + return true, nil + } + } + + return false, nil +} + +// InstanceMetadata returns the instance's metadata. +func (i *instances) InstanceMetadata(ctx context.Context, node *v1.Node) (*cloudprovider.InstanceMetadata, error) { + var err error + var ec2Instance *ec2.Instance + + // TODO: support node name policy other than private DNS names + ec2Instance, err = i.getInstance(ctx, node) + if err != nil { + return nil, err + } + + nodeAddresses, err := nodeAddressesForInstance(ec2Instance) + if err != nil { + return nil, err + } + + providerID, err := getInstanceProviderID(ec2Instance) + if err != nil { + return nil, err + } + + metadata := &cloudprovider.InstanceMetadata{ + ProviderID: providerID, + InstanceType: aws.StringValue(ec2Instance.InstanceType), + NodeAddresses: nodeAddresses, + } + + return metadata, nil +} + +// getInstance returns the instance if the instance with the given node info still exists. +// If false an error will be returned, the instance will be immediately deleted by the cloud controller manager. +func (i *instances) getInstance(ctx context.Context, node *v1.Node) (*ec2.Instance, error) { + var request *ec2.DescribeInstancesInput + if node.Spec.ProviderID == "" { + // get Instance by private DNS name + request = &ec2.DescribeInstancesInput{ + Filters: []*ec2.Filter{ + newEc2Filter("private-dns-name", node.Name), + }, + } + klog.V(4).Infof("looking for node by private DNS name %v", node.Name) + } else { + // get Instance by provider ID + instanceID, err := parseInstanceIDFromProviderID(node.Spec.ProviderID) + if err != nil { + return nil, err + } + + request = &ec2.DescribeInstancesInput{ + InstanceIds: []*string{aws.String(instanceID)}, + } + klog.V(4).Infof("looking for node by provider ID %v", node.Spec.ProviderID) + } + + instances := []*ec2.Instance{} + var nextToken *string + for { + response, err := i.ec2.DescribeInstances(request) + if err != nil { + return nil, fmt.Errorf("error describing ec2 instances: %v", err) + } + + for _, reservation := range response.Reservations { + instances = append(instances, reservation.Instances...) + } + + nextToken = response.NextToken + if aws.StringValue(nextToken) == "" { + break + } + request.NextToken = nextToken + } + + if len(instances) == 0 { + return nil, cloudprovider.InstanceNotFound + } + + if len(instances) > 1 { + return nil, errors.New("getInstance: multiple instances found") + } + + state := instances[0].State.Name + if *state == ec2.InstanceStateNameTerminated { + return nil, cloudprovider.InstanceNotFound + } + + return instances[0], nil +} + +// nodeAddresses for Instance returns a list of v1.NodeAddress for the give instance. +// TODO: should we support ExternalIP by default? +func nodeAddressesForInstance(instance *ec2.Instance) ([]v1.NodeAddress, error) { + if instance == nil { + return nil, errors.New("provided instances is nil") + } + + addresses := []v1.NodeAddress{} + for _, networkInterface := range instance.NetworkInterfaces { + // skip network interfaces that are not currently in use + if aws.StringValue(networkInterface.Status) != ec2.NetworkInterfaceStatusInUse { + continue + } + + for _, privateIP := range networkInterface.PrivateIpAddresses { + if ipAddress := aws.StringValue(privateIP.PrivateIpAddress); ipAddress != "" { + ip := net.ParseIP(ipAddress) + if ip == nil { + return nil, fmt.Errorf("invalid IP address %q from instance %q", ipAddress, aws.StringValue(instance.InstanceId)) + } + + addresses = append(addresses, v1.NodeAddress{ + Type: v1.NodeInternalIP, + Address: ip.String(), + }) + } + } + } + + return addresses, nil +} + +// getInstanceProviderID returns the provider ID of an instance which is ultimately set in the node.Spec.ProviderID field. +// The well-known format for a node's providerID is: +// * aws://// +func getInstanceProviderID(instance *ec2.Instance) (string, error) { + if aws.StringValue(instance.Placement.AvailabilityZone) == "" { + return "", errors.New("instance availability zone was not set") + } + + if aws.StringValue(instance.InstanceId) == "" { + return "", errors.New("instance ID was not set") + } + + return "aws:///" + aws.StringValue(instance.Placement.AvailabilityZone) + "/" + aws.StringValue(instance.InstanceId), nil +} + +// parseInstanceIDFromProviderID parses the node's instance ID based on the following formats: +// * aws:/// +// * aws:/// +// * +// This function always assumes a valid providerID format was provided. +func parseInstanceIDFromProviderID(providerID string) (string, error) { + // trim the provider name prefix 'aws://', renaming providerID should contain metadata in one of the following formats: + // * / + // * // + // * + instanceID := "" + metadata := strings.Split(strings.TrimPrefix(providerID, "aws://"), "/") + if len(metadata) == 1 { + // instance-id + instanceID = metadata[0] + } else if len(metadata) == 2 { + // az/instance-id + instanceID = metadata[1] + } else if len(metadata) == 3 { + // /az/instance-id + instanceID = metadata[2] + } + + return instanceID, nil +} + +func newEc2Filter(name string, values ...string) *ec2.Filter { + filter := &ec2.Filter{ + Name: aws.String(name), + } + + for _, value := range values { + filter.Values = append(filter.Values, aws.String(value)) + } + + return filter +} diff --git a/pkg/providers/v2/instances_test.go b/pkg/providers/v2/instances_test.go new file mode 100644 index 0000000000..e5360d64cf --- /dev/null +++ b/pkg/providers/v2/instances_test.go @@ -0,0 +1,470 @@ +/* +Copyright 2020 The Kubernetes Authors. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package v2 is an out-of-tree only implementation of the AWS cloud provider. +// It is not compatible with v1 and should only be used on new clusters. +package v2 + +import ( + "context" + "fmt" + "testing" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/service/ec2" + "github.com/golang/mock/gomock" + "github.com/google/go-cmp/cmp" + "github.com/stretchr/testify/assert" + + v1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + cloudprovider "k8s.io/cloud-provider" + "k8s.io/cloud-provider-aws/pkg/providers/v2/mocks" +) + +const TestClusterID = "clusterid.test" + +func makeInstance(num int, privateIP, publicIP, privateDNSName, publicDNSName string, stateName string) *ec2.Instance { + instance := ec2.Instance{ + InstanceId: aws.String(fmt.Sprintf("i-%d", num)), + PrivateDnsName: aws.String(privateDNSName), + PrivateIpAddress: aws.String(privateIP), + PublicDnsName: aws.String(publicDNSName), + PublicIpAddress: aws.String(publicIP), + InstanceType: aws.String("c3.large"), + Placement: &ec2.Placement{AvailabilityZone: aws.String("us-west-1a")}, + State: &ec2.InstanceState{ + Name: aws.String(stateName), + }, + NetworkInterfaces: []*ec2.InstanceNetworkInterface{ + { + Status: aws.String(ec2.NetworkInterfaceStatusInUse), + PrivateIpAddresses: []*ec2.InstancePrivateIpAddress{ + { + PrivateIpAddress: aws.String(privateIP), + }, + }, + }, + }, + } + + return &instance +} + +func makeNode(nodeName string) *v1.Node { + providerID := "aws://us-west-1a/i-1234" + return &v1.Node{ + ObjectMeta: metav1.ObjectMeta{ + Name: nodeName, + }, + Spec: v1.NodeSpec{ + ProviderID: providerID, + }, + } +} + +func makeNodeWithoutProviderID(nodeName string) *v1.Node { + return &v1.Node{ + ObjectMeta: metav1.ObjectMeta{ + Name: nodeName, + }, + } +} + +func TestGetInstanceProviderID(t *testing.T) { + testCases := []struct { + name string + instance *ec2.Instance + providerID string + }{ + { + name: "get instance (regular) provider ID", + instance: makeInstance(0, "192.168.0.1", "1.2.3.4", "instance-same.ec2.internal", "instance-same.ec2.external", "running"), + providerID: "aws:///us-west-1a/i-0", + }, + { + name: "get instance (without public IP/DNS) provider ID", + instance: makeInstance(1, "192.168.0.2", "", "instance-same.ec2.internal", "", "running"), + providerID: "aws:///us-west-1a/i-1", + }, + { + name: "get instance (without private IP/DNS) provider ID", + instance: makeInstance(2, "", "1.2.3.4", "", "instance-same.ec2.external", "running"), + providerID: "aws:///us-west-1a/i-2", + }, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + providerID, err := getInstanceProviderID(testCase.instance) + assert.NoError(t, err) + assert.Equal(t, testCase.providerID, providerID) + }) + } +} + +func TestInstanceExists(t *testing.T) { + mockCtrl := gomock.NewController(t) + defer mockCtrl.Finish() + + mockEC2 := mocks.NewMockEC2(mockCtrl) + + fakeInstances := &instances{ + ec2: mockEC2, + } + + nodeName := "ip-192-168-0-1.ec2.internal" + + tests := []struct { + name string + node *v1.Node + mockedEC2Output *ec2.DescribeInstancesOutput + expectedResult bool + }{ + { + name: "test InstanceExists with running instance", + node: makeNode(nodeName), + mockedEC2Output: &ec2.DescribeInstancesOutput{ + Reservations: []*ec2.Reservation{ + { + Instances: []*ec2.Instance{ + makeInstance(0, "192.168.0.1", "1.2.3.4", "instance-same.ec2.internal", "instance-same.ec2.external", "running"), + }, + }, + }, + }, + expectedResult: true, + }, + { + name: "test InstanceExists with stopping instance", + node: makeNode(nodeName), + mockedEC2Output: &ec2.DescribeInstancesOutput{ + Reservations: []*ec2.Reservation{ + { + Instances: []*ec2.Instance{ + makeInstance(1, "192.168.0.1", "1.2.3.4", "instance-same.ec2.internal", "instance-same.ec2.external", "stopping"), + }, + }, + }, + }, + expectedResult: true, + }, + { + name: "test InstanceExists with terminated instance (node without providerID)", + node: makeNodeWithoutProviderID(nodeName), + mockedEC2Output: &ec2.DescribeInstancesOutput{ + Reservations: []*ec2.Reservation{ + { + Instances: []*ec2.Instance{}, + }, + }, + }, + expectedResult: false, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + mockEC2.EXPECT().DescribeInstances(gomock.Any()).Return(test.mockedEC2Output, nil) + + exists, err := fakeInstances.InstanceExists(context.TODO(), test.node) + + if err != nil { + t.Errorf("InstanceExists failed with node %v: %v", nodeName, err) + } + + if exists != test.expectedResult { + t.Errorf("unexpected result, InstanceExists should return %v", test.expectedResult) + } + }) + } +} + +func TestInstanceShutdown(t *testing.T) { + mockCtrl := gomock.NewController(t) + defer mockCtrl.Finish() + + mockEC2 := mocks.NewMockEC2(mockCtrl) + + fakeInstances := &instances{ + ec2: mockEC2, + } + + nodeName := "ip-192-168-0-1.ec2.internal" + + tests := []struct { + name string + node *v1.Node + mockedEC2Output *ec2.DescribeInstancesOutput + expectedResult bool + }{ + { + name: "test InstanceShutdown with running instance", + node: makeNode(nodeName), + mockedEC2Output: &ec2.DescribeInstancesOutput{ + Reservations: []*ec2.Reservation{ + { + Instances: []*ec2.Instance{ + makeInstance(0, "192.168.0.1", "1.2.3.6", "instance-same.ec2.internal", "instance-same.ec2.external", "running"), + }, + }, + }, + }, + expectedResult: false, + }, + { + name: "test InstanceShutdown with running instance (node without providerID)", + node: makeNodeWithoutProviderID(nodeName), + mockedEC2Output: &ec2.DescribeInstancesOutput{ + Reservations: []*ec2.Reservation{ + { + Instances: []*ec2.Instance{ + makeInstance(0, "192.168.0.1", "1.2.3.6", "instance-same.ec2.internal", "instance-same.ec2.external", "running"), + }, + }, + }, + }, + expectedResult: false, + }, + { + name: "test InstanceShutdown with stopping instance", + node: makeNode(nodeName), + mockedEC2Output: &ec2.DescribeInstancesOutput{ + Reservations: []*ec2.Reservation{ + { + Instances: []*ec2.Instance{ + makeInstance(0, "192.168.0.1", "1.2.3.6", "instance-same.ec2.internal", "instance-same.ec2.external", "stopping"), + }, + }, + }, + }, + expectedResult: false, + }, + { + name: "test InstanceShutdown with stopped instance (node without providerID)", + node: makeNodeWithoutProviderID(nodeName), + mockedEC2Output: &ec2.DescribeInstancesOutput{ + Reservations: []*ec2.Reservation{ + { + Instances: []*ec2.Instance{ + makeInstance(0, "192.168.0.1", "1.2.3.6", "instance-same.ec2.internal", "instance-same.ec2.external", "stopped"), + }, + }, + }, + }, + expectedResult: true, + }, + { + name: "test InstanceShutdown with terminated instance", + node: makeNode(nodeName), + mockedEC2Output: &ec2.DescribeInstancesOutput{ + Reservations: []*ec2.Reservation{ + { + Instances: []*ec2.Instance{ + makeInstance(0, "192.168.0.1", "1.2.3.6", "instance-same.ec2.internal", "instance-same.ec2.external", "terminated"), + }, + }, + }, + }, + expectedResult: false, + }, + { + name: "test InstanceShutdown with terminated instance (node without provierID)", + node: makeNodeWithoutProviderID(nodeName), + mockedEC2Output: &ec2.DescribeInstancesOutput{ + Reservations: []*ec2.Reservation{ + { + Instances: []*ec2.Instance{ + makeInstance(0, "192.168.0.1", "1.2.3.6", "instance-same.ec2.internal", "instance-same.ec2.external", "terminated"), + }, + }, + }, + }, + expectedResult: false, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + mockEC2.EXPECT().DescribeInstances(gomock.Any()).Return(test.mockedEC2Output, nil) + + shutdown, err := fakeInstances.InstanceShutdown(context.TODO(), test.node) + + if err != nil { + t.Logf("InstanceShutdown failed with node %v: %v", nodeName, err) + } + + if shutdown != test.expectedResult { + t.Errorf("unexpected result, InstanceShutdown should return %v", test.expectedResult) + } + }) + } +} + +func TestInstanceMetadata(t *testing.T) { + mockCtrl := gomock.NewController(t) + defer mockCtrl.Finish() + + mockEC2 := mocks.NewMockEC2(mockCtrl) + + fakeInstances := &instances{ + ec2: mockEC2, + } + + nodeName := "ip-192-168-0-1.ec2.internal" + + tests := []struct { + name string + node *v1.Node + expectedResult *cloudprovider.InstanceMetadata + mockedEC2Output *ec2.DescribeInstancesOutput + }{ + { + name: "test InstanceMetadata with running instance", + node: makeNode(nodeName), + mockedEC2Output: &ec2.DescribeInstancesOutput{ + Reservations: []*ec2.Reservation{ + { + Instances: []*ec2.Instance{ + makeInstance(0, "192.168.0.1", "1.2.3.6", "instance-same.ec2.internal", "instance-same.ec2.external", "running"), + }, + }, + }, + }, + expectedResult: &cloudprovider.InstanceMetadata{ + ProviderID: "aws:///us-west-1a/i-0", + InstanceType: "c3.large", + NodeAddresses: []v1.NodeAddress{ + { + Type: "InternalIP", + Address: "192.168.0.1", + }, + }, + }, + }, + { + name: "test InstanceMetadata with running instance (node without providerID)", + node: makeNodeWithoutProviderID(nodeName), + mockedEC2Output: &ec2.DescribeInstancesOutput{ + Reservations: []*ec2.Reservation{ + { + Instances: []*ec2.Instance{ + makeInstance(0, "192.168.0.1", "1.2.3.6", "instance-same.ec2.internal", "instance-same.ec2.external", "running"), + }, + }, + }, + }, + expectedResult: &cloudprovider.InstanceMetadata{ + ProviderID: "aws:///us-west-1a/i-0", + InstanceType: "c3.large", + NodeAddresses: []v1.NodeAddress{ + { + Type: "InternalIP", + Address: "192.168.0.1", + }, + }, + }, + }, + { + name: "test InstanceMetadata with stopping instance", + node: makeNode(nodeName), + mockedEC2Output: &ec2.DescribeInstancesOutput{ + Reservations: []*ec2.Reservation{ + { + Instances: []*ec2.Instance{ + makeInstance(1, "192.168.0.1", "1.2.3.6", "instance-same.ec2.internal", "instance-same.ec2.external", "stopping"), + }, + }, + }, + }, + expectedResult: &cloudprovider.InstanceMetadata{ + ProviderID: "aws:///us-west-1a/i-1", + InstanceType: "c3.large", + NodeAddresses: []v1.NodeAddress{ + { + Type: "InternalIP", + Address: "192.168.0.1", + }, + }, + }, + }, + { + name: "test InstanceMetadata with terminated instance", + node: makeNode(nodeName), + mockedEC2Output: &ec2.DescribeInstancesOutput{ + Reservations: []*ec2.Reservation{ + { + Instances: []*ec2.Instance{}, + }, + }, + }, + expectedResult: nil, + }, + { + name: "test InstanceMetadata with terminated instance (node without providerID)", + node: makeNodeWithoutProviderID(nodeName), + mockedEC2Output: &ec2.DescribeInstancesOutput{ + Reservations: []*ec2.Reservation{ + { + Instances: []*ec2.Instance{}, + }, + }, + }, + expectedResult: nil, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + mockEC2.EXPECT().DescribeInstances(gomock.Any()).Return(test.mockedEC2Output, nil) + + nodeName := "ip-172-21-32-3.ec2.internal" + node := &v1.Node{ + ObjectMeta: metav1.ObjectMeta{ + Name: nodeName, + }, + } + metadata, err := fakeInstances.InstanceMetadata(context.TODO(), node) + + if err != nil { + t.Logf("InstanceMetadata failed with node %v: %v", nodeName, err) + } + + if !cmp.Equal(metadata, test.expectedResult) { + t.Errorf("unexpected metadata %v, InstanceMetadata should return %v", metadata, test.expectedResult) + } + }) + } +} + +func TestParseInstanceIDFromProviderID(t *testing.T) { + testCases := []struct { + providerID string + instanceID string + }{ + {"aws://eu-central-1a/i-1238asjd8asdm123", "i-1238asjd8asdm123"}, + {"aws://us-west-2a/i-112as321asjd8asdm23", "i-112as321asjd8asdm23"}, + {"aws://us-iso-east-1a/i-123", "i-123"}, + {"aws://us-isob-east-1a/i-abcdef", "i-abcdef"}, + {"aws:///us-isob-east-1a/i-abCDef", "i-abCDef"}, + {"aws://us-east-1a/8asdm23", "8asdm23"}, + {"aws:///us-west-2a/i-0226b64168e09815e", "i-0226b64168e09815e"}, + {"i-0226b64168e09815e", "i-0226b64168e09815e"}, + } + + for _, testCase := range testCases { + ret, err := parseInstanceIDFromProviderID(testCase.providerID) + assert.NoError(t, err) + assert.Equal(t, testCase.instanceID, ret) + } +} diff --git a/pkg/providers/v2/mocks/mock_ec2.go b/pkg/providers/v2/mocks/mock_ec2.go new file mode 100644 index 0000000000..9818a8fde0 --- /dev/null +++ b/pkg/providers/v2/mocks/mock_ec2.go @@ -0,0 +1,62 @@ +/* +Copyright The Kubernetes Authors. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Code generated by MockGen. DO NOT EDIT. +// Source: instances.go + +// Package mocks is a generated GoMock package. +package mocks + +import ( + ec2 "github.com/aws/aws-sdk-go/service/ec2" + gomock "github.com/golang/mock/gomock" + reflect "reflect" +) + +// MockEC2 is a mock of EC2 interface +type MockEC2 struct { + ctrl *gomock.Controller + recorder *MockEC2MockRecorder +} + +// MockEC2MockRecorder is the mock recorder for MockEC2 +type MockEC2MockRecorder struct { + mock *MockEC2 +} + +// NewMockEC2 creates a new mock instance +func NewMockEC2(ctrl *gomock.Controller) *MockEC2 { + mock := &MockEC2{ctrl: ctrl} + mock.recorder = &MockEC2MockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use +func (m *MockEC2) EXPECT() *MockEC2MockRecorder { + return m.recorder +} + +// DescribeInstances mocks base method +func (m *MockEC2) DescribeInstances(request *ec2.DescribeInstancesInput) (*ec2.DescribeInstancesOutput, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DescribeInstances", request) + ret0, _ := ret[0].(*ec2.DescribeInstancesOutput) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// DescribeInstances indicates an expected call of DescribeInstances +func (mr *MockEC2MockRecorder) DescribeInstances(request interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DescribeInstances", reflect.TypeOf((*MockEC2)(nil).DescribeInstances), request) +}