Repository: kubernetes-sigs/aws-alb-ingress-controller
Pull request #1410 (2020-09-11 20:46:58)

add two equality:FilterField and equality:IgnoreStringSliceOrder
add two equality function:
1. `equality:FilterField` applies specific cmp option to certain struct field only.
2. `equality:IgnoreStringSliceOrder` ignores order for comparing `[]string`


k8s-ci-robot: [APPROVALNOTIFIER] This PR is **NOT APPROVED**

This pull-request has been approved by: *<a href="https://github.com/kubernetes-sigs/aws-alb-ingress-controller/pull/1410#" title="Author self-approved">M00nF1sh</a>*
To complete the [pull request process](https://git.k8s.io/community/contributors/guide/owners.md#the-code-review-process), please assign 
You can assign the PR to them by writing `/assign ` in a comment when ready.

The full list of commands accepted by this bot can be found [here](https://go.k8s.io/bot-commands?repo=kubernetes-sigs%2Faws-alb-ingress-controller).

<details open>
Needs approval from an approver in each of these files:

- **[OWNERS](https://github.com/kubernetes-sigs/aws-alb-ingress-controller/blob/v2_ga/OWNERS)**

Approvers can indicate their approval by writing `/approve` in a comment
Approvers can cancel approval by writing `/approve cancel` in a comment
</details>
<!-- META={"approvers":[]} -->

Patch:
diff --git a/mocks/aws/services/mock_ec2.go b/mocks/aws/services/mock_ec2.go
index 89ff7520f..8ec46d31c 100644
--- a/mocks/aws/services/mock_ec2.go
+++ b/mocks/aws/services/mock_ec2.go
@@ -10508,6 +10508,21 @@ func (mr *MockEC2MockRecorder) DescribeInstances(arg0 interface{}) *gomock.Call
 	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DescribeInstances", reflect.TypeOf((*MockEC2)(nil).DescribeInstances), arg0)
 }
 
+// DescribeInstancesAsList mocks base method
+func (m *MockEC2) DescribeInstancesAsList(arg0 context.Context, arg1 *ec2.DescribeInstancesInput) ([]*ec2.Instance, error) {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "DescribeInstancesAsList", arg0, arg1)
+	ret0, _ := ret[0].([]*ec2.Instance)
+	ret1, _ := ret[1].(error)
+	return ret0, ret1
+}
+
+// DescribeInstancesAsList indicates an expected call of DescribeInstancesAsList
+func (mr *MockEC2MockRecorder) DescribeInstancesAsList(arg0, arg1 interface{}) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DescribeInstancesAsList", reflect.TypeOf((*MockEC2)(nil).DescribeInstancesAsList), arg0, arg1)
+}
+
 // DescribeInstancesPages mocks base method
 func (m *MockEC2) DescribeInstancesPages(arg0 *ec2.DescribeInstancesInput, arg1 func(*ec2.DescribeInstancesOutput, bool) bool) error {
 	m.ctrl.T.Helper()
@@ -11936,6 +11951,21 @@ func (mr *MockEC2MockRecorder) DescribeNetworkInterfaces(arg0 interface{}) *gomo
 	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DescribeNetworkInterfaces", reflect.TypeOf((*MockEC2)(nil).DescribeNetworkInterfaces), arg0)
 }
 
+// DescribeNetworkInterfacesAsList mocks base method
+func (m *MockEC2) DescribeNetworkInterfacesAsList(arg0 context.Context, arg1 *ec2.DescribeNetworkInterfacesInput) ([]*ec2.NetworkInterface, error) {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "DescribeNetworkInterfacesAsList", arg0, arg1)
+	ret0, _ := ret[0].([]*ec2.NetworkInterface)
+	ret1, _ := ret[1].(error)
+	return ret0, ret1
+}
+
+// DescribeNetworkInterfacesAsList indicates an expected call of DescribeNetworkInterfacesAsList
+func (mr *MockEC2MockRecorder) DescribeNetworkInterfacesAsList(arg0, arg1 interface{}) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DescribeNetworkInterfacesAsList", reflect.TypeOf((*MockEC2)(nil).DescribeNetworkInterfacesAsList), arg0, arg1)
+}
+
 // DescribeNetworkInterfacesPages mocks base method
 func (m *MockEC2) DescribeNetworkInterfacesPages(arg0 *ec2.DescribeNetworkInterfacesInput, arg1 func(*ec2.DescribeNetworkInterfacesOutput, bool) bool) error {
 	m.ctrl.T.Helper()
@@ -12933,6 +12963,21 @@ func (mr *MockEC2MockRecorder) DescribeSecurityGroups(arg0 interface{}) *gomock.
 	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DescribeSecurityGroups", reflect.TypeOf((*MockEC2)(nil).DescribeSecurityGroups), arg0)
 }
 
+// DescribeSecurityGroupsAsList mocks base method
+func (m *MockEC2) DescribeSecurityGroupsAsList(arg0 context.Context, arg1 *ec2.DescribeSecurityGroupsInput) ([]*ec2.SecurityGroup, error) {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "DescribeSecurityGroupsAsList", arg0, arg1)
+	ret0, _ := ret[0].([]*ec2.SecurityGroup)
+	ret1, _ := ret[1].(error)
+	return ret0, ret1
+}
+
+// DescribeSecurityGroupsAsList indicates an expected call of DescribeSecurityGroupsAsList
+func (mr *MockEC2MockRecorder) DescribeSecurityGroupsAsList(arg0, arg1 interface{}) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DescribeSecurityGroupsAsList", reflect.TypeOf((*MockEC2)(nil).DescribeSecurityGroupsAsList), arg0, arg1)
+}
+
 // DescribeSecurityGroupsPages mocks base method
 func (m *MockEC2) DescribeSecurityGroupsPages(arg0 *ec2.DescribeSecurityGroupsInput, arg1 func(*ec2.DescribeSecurityGroupsOutput, bool) bool) error {
 	m.ctrl.T.Helper()
diff --git a/pkg/aws/services/ec2.go b/pkg/aws/services/ec2.go
index 8186eb478..3a6eb3562 100644
--- a/pkg/aws/services/ec2.go
+++ b/pkg/aws/services/ec2.go
@@ -1,6 +1,7 @@
 package services
 
 import (
+	"context"
 	"github.com/aws/aws-sdk-go/aws/session"
 	"github.com/aws/aws-sdk-go/service/ec2"
 	"github.com/aws/aws-sdk-go/service/ec2/ec2iface"
@@ -8,6 +9,15 @@ import (
 
 type EC2 interface {
 	ec2iface.EC2API
+
+	// wrapper to DescribeInstancesPagesWithContext API, which aggregates paged results into list.
+	DescribeInstancesAsList(ctx context.Context, input *ec2.DescribeInstancesInput) ([]*ec2.Instance, error)
+
+	// wrapper to DescribeNetworkInterfacesPagesWithContext API, which aggregates paged results into list.
+	DescribeNetworkInterfacesAsList(ctx context.Context, input *ec2.DescribeNetworkInterfacesInput) ([]*ec2.NetworkInterface, error)
+
+	// wrapper to DescribeSecurityGroupsPagesWithContext API, which aggregates paged results into list.
+	DescribeSecurityGroupsAsList(ctx context.Context, input *ec2.DescribeSecurityGroupsInput) ([]*ec2.SecurityGroup, error)
 }
 
 // NewEC2 constructs new EC2 implementation.
@@ -20,3 +30,38 @@ func NewEC2(session *session.Session) EC2 {
 type defaultEC2 struct {
 	ec2iface.EC2API
 }
+
+func (c *defaultEC2) DescribeInstancesAsList(ctx context.Context, input *ec2.DescribeInstancesInput) ([]*ec2.Instance, error) {
+	var result []*ec2.Instance
+	if err := c.DescribeInstancesPagesWithContext(ctx, input, func(output *ec2.DescribeInstancesOutput, _ bool) bool {
+		for _, reservation := range output.Reservations {
+			result = append(result, reservation.Instances...)
+		}
+		return true
+	}); err != nil {
+		return nil, err
+	}
+	return result, nil
+}
+
+func (c *defaultEC2) DescribeNetworkInterfacesAsList(ctx context.Context, input *ec2.DescribeNetworkInterfacesInput) ([]*ec2.NetworkInterface, error) {
+	var result []*ec2.NetworkInterface
+	if err := c.DescribeNetworkInterfacesPagesWithContext(ctx, input, func(output *ec2.DescribeNetworkInterfacesOutput, _ bool) bool {
+		result = append(result, output.NetworkInterfaces...)
+		return true
+	}); err != nil {
+		return nil, err
+	}
+	return result, nil
+}
+
+func (c *defaultEC2) DescribeSecurityGroupsAsList(ctx context.Context, input *ec2.DescribeSecurityGroupsInput) ([]*ec2.SecurityGroup, error) {
+	var result []*ec2.SecurityGroup
+	if err := c.DescribeSecurityGroupsPagesWithContext(ctx, input, func(output *ec2.DescribeSecurityGroupsOutput, _ bool) bool {
+		result = append(result, output.SecurityGroups...)
+		return true
+	}); err != nil {
+		return nil, err
+	}
+	return result, nil
+}
diff --git a/pkg/backend/endpoint_resolver.go b/pkg/backend/endpoint_resolver.go
index b1904a67f..460353ec2 100644
--- a/pkg/backend/endpoint_resolver.go
+++ b/pkg/backend/endpoint_resolver.go
@@ -10,7 +10,6 @@ import (
 	"k8s.io/apimachinery/pkg/util/intstr"
 	"sigs.k8s.io/aws-alb-ingress-controller/pkg/k8s"
 	"sigs.k8s.io/controller-runtime/pkg/client"
-	"strings"
 )
 
 // EndpointResolver resolves the endpoints for specific service & service Port.
@@ -118,7 +117,7 @@ func (r *defaultEndpointResolver) ResolveNodePortEndpoints(ctx context.Context,
 		if !k8s.IsNodeReady(node) {
 			continue
 		}
-		instanceID, err := extractNodeInstanceID(node)
+		instanceID, err := k8s.ExtractNodeInstanceID(node)
 		if err != nil {
 			return nil, err
 		}
@@ -159,16 +158,6 @@ func isPodMeetCriteria(pod *corev1.Pod, criteria []PodPredicate) bool {
 	return true
 }
 
-func extractNodeInstanceID(node *corev1.Node) (string, error) {
-	providerID := node.Spec.ProviderID
-	if providerID == "" {
-		return "", errors.Errorf("providerID is not specified for node: %s", node.Name)
-	}
-
-	p := strings.Split(providerID, "/")
-	return p[len(p)-1], nil
-}
-
 func buildPodEndpoint(pod *corev1.Pod, epAddr corev1.EndpointAddress, epPort corev1.EndpointPort) PodEndpoint {
 	return PodEndpoint{
 		IP:   epAddr.IP,
diff --git a/pkg/equality/filter_field.go b/pkg/equality/filter_field.go
new file mode 100644
index 000000000..b9d62dcf9
--- /dev/null
+++ b/pkg/equality/filter_field.go
@@ -0,0 +1,21 @@
+package equality
+
+import (
+	"github.com/google/go-cmp/cmp"
+	"reflect"
+)
+
+// FilterField return a new option that only apply specified option to specific struct field.
+func FilterField(typ interface{}, field string, opt cmp.Option) cmp.Option {
+	t := reflect.TypeOf(typ)
+	return cmp.FilterPath(func(path cmp.Path) bool {
+		if len(path) < 2 || path.Index(-2).Type() != t {
+			return false
+		}
+		ps, ok := path.Last().(cmp.StructField)
+		if !ok || field != ps.Name() {
+			return false
+		}
+		return true
+	}, opt)
+}
diff --git a/pkg/equality/ignore_string_slice_order.go b/pkg/equality/ignore_string_slice_order.go
new file mode 100644
index 000000000..252ce5373
--- /dev/null
+++ b/pkg/equality/ignore_string_slice_order.go
@@ -0,0 +1,13 @@
+package equality
+
+import (
+	"github.com/google/go-cmp/cmp"
+	"github.com/google/go-cmp/cmp/cmpopts"
+)
+
+// IgnoreStringSliceOrder is option that compare string slices without order.
+func IgnoreStringSliceOrder() cmp.Option {
+	return cmpopts.SortSlices(func(lhs string, rhs string) bool {
+		return lhs < rhs
+	})
+}
diff --git a/pkg/k8s/node_utils.go b/pkg/k8s/node_utils.go
index 42002bd65..efbda4968 100644
--- a/pkg/k8s/node_utils.go
+++ b/pkg/k8s/node_utils.go
@@ -1,6 +1,10 @@
 package k8s
 
-import corev1 "k8s.io/api/core/v1"
+import (
+	"github.com/pkg/errors"
+	corev1 "k8s.io/api/core/v1"
+	"strings"
+)
 
 // IsNodeReady returns whether node is ready.
 func IsNodeReady(node *corev1.Node) bool {
@@ -18,3 +22,13 @@ func GetNodeCondition(node *corev1.Node, conditionType corev1.NodeConditionType)
 	}
 	return nil
 }
+
+func ExtractNodeInstanceID(node *corev1.Node) (string, error) {
+	providerID := node.Spec.ProviderID
+	if providerID == "" {
+		return "", errors.Errorf("providerID is not specified for node: %s", node.Name)
+	}
+
+	p := strings.Split(providerID, "/")
+	return p[len(p)-1], nil
+}
diff --git a/pkg/networking/eni_info.go b/pkg/networking/eni_info.go
new file mode 100644
index 000000000..685da6f42
--- /dev/null
+++ b/pkg/networking/eni_info.go
@@ -0,0 +1,37 @@
+package networking
+
+import (
+	awssdk "github.com/aws/aws-sdk-go/aws"
+	"github.com/aws/aws-sdk-go/service/ec2"
+)
+
+// ENIInfo wraps necessary information about a ENI.
+type ENIInfo struct {
+	// ENI's ID
+	NetworkInterfaceID string
+
+	// SecurityGroups on ENI
+	SecurityGroups []string
+}
+
+func buildENIInfoViaENI(eni *ec2.NetworkInterface) ENIInfo {
+	sgIDs := make([]string, 0, len(eni.Groups))
+	for _, group := range eni.Groups {
+		sgIDs = append(sgIDs, awssdk.StringValue(group.GroupId))
+	}
+	return ENIInfo{
+		NetworkInterfaceID: awssdk.StringValue(eni.NetworkInterfaceId),
+		SecurityGroups:     sgIDs,
+	}
+}
+
+func buildENIInfoViaInstanceENI(eni *ec2.InstanceNetworkInterface) ENIInfo {
+	sgIDs := make([]string, 0, len(eni.Groups))
+	for _, group := range eni.Groups {
+		sgIDs = append(sgIDs, awssdk.StringValue(group.GroupId))
+	}
+	return ENIInfo{
+		NetworkInterfaceID: awssdk.StringValue(eni.NetworkInterfaceId),
+		SecurityGroups:     sgIDs,
+	}
+}
diff --git a/pkg/networking/node_eni_info_resolver.go b/pkg/networking/node_eni_info_resolver.go
new file mode 100644
index 000000000..6f7244491
--- /dev/null
+++ b/pkg/networking/node_eni_info_resolver.go
@@ -0,0 +1,181 @@
+package networking
+
+import (
+	"context"
+	awssdk "github.com/aws/aws-sdk-go/aws"
+	ec2sdk "github.com/aws/aws-sdk-go/service/ec2"
+	"github.com/go-logr/logr"
+	"github.com/pkg/errors"
+	corev1 "k8s.io/api/core/v1"
+	"k8s.io/apimachinery/pkg/types"
+	"k8s.io/apimachinery/pkg/util/cache"
+	"k8s.io/apimachinery/pkg/util/sets"
+	"sigs.k8s.io/aws-alb-ingress-controller/pkg/aws/services"
+	"sigs.k8s.io/aws-alb-ingress-controller/pkg/k8s"
+	"sync"
+	"time"
+)
+
+const (
+	defaultNodeENIInfoCacheTTL = 10 * time.Minute
+)
+
+// NodeENIInfoResolver is responsible for resolve the AWS VPC ENI that supports node network.
+type NodeENIInfoResolver interface {
+	Resolve(ctx context.Context, nodes []*corev1.Node) (map[types.NamespacedName]ENIInfo, error)
+}
+
+// NewDefaultNodeENIInfoResolver constructs new defaultNodeENIInfoResolver.
+func NewDefaultNodeENIInfoResolver(ec2Client services.EC2, logger logr.Logger) *defaultNodeENIInfoResolver {
+	return &defaultNodeENIInfoResolver{
+		ec2Client:             ec2Client,
+		logger:                logger,
+		nodeENIInfoCache:      cache.NewExpiring(),
+		nodeENIInfoCacheMutex: sync.RWMutex{},
+		nodeENIInfoCacheTTL:   defaultNodeENIInfoCacheTTL,
+	}
+}
+
+var _ NodeENIInfoResolver = &defaultNodeENIInfoResolver{}
+
+// default implementation for NodeENIInfoResolver.
+type defaultNodeENIInfoResolver struct {
+	// ec2 client
+	ec2Client services.EC2
+	// logger
+	logger logr.Logger
+
+	nodeENIInfoCache      *cache.Expiring
+	nodeENIInfoCacheMutex sync.RWMutex
+	nodeENIInfoCacheTTL   time.Duration
+}
+
+func (r *defaultNodeENIInfoResolver) Resolve(ctx context.Context, nodes []*corev1.Node) (map[types.NamespacedName]ENIInfo, error) {
+	eniInfoByNodeKey := r.fetchENIInfosFromCache(nodes)
+	unresolvedNodes := computeNodesWithUnresolvedENIInfo(nodes, eniInfoByNodeKey)
+	eniInfoByNodeKeyViaLookup, err := r.resolveViaInstanceID(ctx, unresolvedNodes)
+	if err != nil {
+		return nil, err
+	}
+	if len(eniInfoByNodeKeyViaLookup) > 0 {
+		r.saveENIInfosToCache(unresolvedNodes, eniInfoByNodeKeyViaLookup)
+		for nodeKey, eniInfo := range eniInfoByNodeKeyViaLookup {
+			eniInfoByNodeKey[nodeKey] = eniInfo
+		}
+		unresolvedNodes = computeNodesWithUnresolvedENIInfo(unresolvedNodes, eniInfoByNodeKeyViaLookup)
+	}
+
+	if len(unresolvedNodes) > 0 {
+		unresolvedNodeKeys := make([]types.NamespacedName, 0, len(unresolvedNodes))
+		for _, node := range unresolvedNodes {
+			unresolvedNodeKeys = append(unresolvedNodeKeys, k8s.NamespacedName(node))
+		}
+		return nil, errors.Errorf("cannot resolve node ENI for nodes: %v", unresolvedNodeKeys)
+	}
+	return eniInfoByNodeKey, nil
+}
+
+type nodeENIInfoCacheKey struct {
+	// Node's key
+	nodeKey types.NamespacedName
+	// Node's UID.
+	// Note: we assume node's eni haven't changed as long as node UID is same.
+	nodeUID string
+}
+
+func (r *defaultNodeENIInfoResolver) fetchENIInfosFromCache(nodes []*corev1.Node) map[types.NamespacedName]ENIInfo {
+	r.nodeENIInfoCacheMutex.RLock()
+	defer r.nodeENIInfoCacheMutex.RUnlock()
+
+	eniInfoByNodeKey := make(map[types.NamespacedName]ENIInfo)
+	for _, node := range nodes {
+		cacheKey := computeNodeENIInfoCacheKey(node)
+		if rawCacheItem, exists := r.nodeENIInfoCache.Get(cacheKey); exists {
+			eniInfo := rawCacheItem.(ENIInfo)
+			nodeKey := k8s.NamespacedName(node)
+			eniInfoByNodeKey[nodeKey] = eniInfo
+		}
+	}
+	return eniInfoByNodeKey
+}
+
+func (r *defaultNodeENIInfoResolver) saveENIInfosToCache(nodes []*corev1.Node, eniInfoByNodeKey map[types.NamespacedName]ENIInfo) {
+	r.nodeENIInfoCacheMutex.Lock()
+	defer r.nodeENIInfoCacheMutex.Unlock()
+
+	for _, node := range nodes {
+		nodeKey := k8s.NamespacedName(node)
+		if eniInfo, exists := eniInfoByNodeKey[nodeKey]; exists {
+			cacheKey := computeNodeENIInfoCacheKey(node)
+			r.nodeENIInfoCache.Set(cacheKey, eniInfo, r.nodeENIInfoCacheTTL)
+		}
+	}
+}
+
+func (r *defaultNodeENIInfoResolver) resolveViaInstanceID(ctx context.Context, nodes []*corev1.Node) (map[types.NamespacedName]ENIInfo, error) {
+	nodeKeysByInstanceID := make(map[string][]types.NamespacedName)
+	for _, node := range nodes {
+		instanceID, err := k8s.ExtractNodeInstanceID(node)
+		if err != nil {
+			return nil, err
+		}
+		nodeKey := k8s.NamespacedName(node)
+		nodeKeysByInstanceID[instanceID] = append(nodeKeysByInstanceID[instanceID], nodeKey)
+	}
+	if len(nodeKeysByInstanceID) == 0 {
+		return nil, nil
+	}
+
+	instanceIDs := sets.StringKeySet(nodeKeysByInstanceID).List()
+	req := &ec2sdk.DescribeInstancesInput{
+		InstanceIds: awssdk.StringSlice(instanceIDs),
+	}
+	instances, err := r.ec2Client.DescribeInstancesAsList(ctx, req)
+	if err != nil {
+		return nil, err
+	}
+
+	eniInfoByNodeKey := make(map[types.NamespacedName]ENIInfo)
+	for _, instance := range instances {
+		instanceID := awssdk.StringValue(instance.InstanceId)
+		primaryENI, err := findInstancePrimaryENI(instance.NetworkInterfaces)
+		if err != nil {
+			return nil, err
+		}
+		eniInfo := buildENIInfoViaInstanceENI(primaryENI)
+		for _, nodeKey := range nodeKeysByInstanceID[instanceID] {
+			eniInfoByNodeKey[nodeKey] = eniInfo
+		}
+	}
+	return eniInfoByNodeKey, nil
+}
+
+// findInstancePrimaryENI returns the primary ENI among list of eni on an EC2 instance
+func findInstancePrimaryENI(enis []*ec2sdk.InstanceNetworkInterface) (*ec2sdk.InstanceNetworkInterface, error) {
+	for _, eni := range enis {
+		if awssdk.Int64Value(eni.Attachment.DeviceIndex) == 0 {
+			return eni, nil
+		}
+	}
+	return nil, errors.Errorf("[this should never happen] no primary ENI found")
+}
+
+// computeNodeENIInfoCacheKey computes the cacheKey for node's ENIInfo cache.
+func computeNodeENIInfoCacheKey(node *corev1.Node) nodeENIInfoCacheKey {
+	return nodeENIInfoCacheKey{
+		nodeKey: k8s.NamespacedName(node),
+		nodeUID: string(node.UID),
+	}
+}
+
+// computeNodesWithUnresolvedENIInfo computes nodes that don't have resolvedENIInfo.
+func computeNodesWithUnresolvedENIInfo(nodes []*corev1.Node, eniInfoByNodeKey map[types.NamespacedName]ENIInfo) []*corev1.Node {
+	unresolvedNodes := make([]*corev1.Node, 0, len(nodes)-len(eniInfoByNodeKey))
+	for _, node := range nodes {
+		nodeKey := k8s.NamespacedName(node)
+		if _, ok := eniInfoByNodeKey[nodeKey]; !ok {
+			unresolvedNodes = append(unresolvedNodes, node)
+		}
+	}
+	return unresolvedNodes
+}
diff --git a/pkg/networking/pod_eni_info_resolver.go b/pkg/networking/pod_eni_info_resolver.go
new file mode 100644
index 000000000..ff53dd52b
--- /dev/null
+++ b/pkg/networking/pod_eni_info_resolver.go
@@ -0,0 +1,275 @@
+package networking
+
+import (
+	"context"
+	"encoding/json"
+	awssdk "github.com/aws/aws-sdk-go/aws"
+	ec2sdk "github.com/aws/aws-sdk-go/service/ec2"
+	"github.com/go-logr/logr"
+	"github.com/pkg/errors"
+	corev1 "k8s.io/api/core/v1"
+	"k8s.io/apimachinery/pkg/types"
+	"k8s.io/apimachinery/pkg/util/cache"
+	"k8s.io/apimachinery/pkg/util/sets"
+	"sigs.k8s.io/aws-alb-ingress-controller/pkg/algorithm"
+	"sigs.k8s.io/aws-alb-ingress-controller/pkg/aws/services"
+	"sigs.k8s.io/aws-alb-ingress-controller/pkg/k8s"
+	"sync"
+	"time"
+)
+
+const (
+	annotationPodENI = "vpc.amazonaws.com/pod-eni"
+
+	defaultPodENIInfoCacheTTL = 10 * time.Minute
+	// EC2:DescribeNetworkInterface supports up to 200 filters per call.
+	describeNetworkInterfacesFiltersLimit = 200
+)
+
+// PodENIInfoResolver is responsible for resolve the AWS VPC ENI that supports pod network.
+type PodENIInfoResolver interface {
+	// Resolve resolves eniInfo for pods.
+	Resolve(ctx context.Context, pods []*corev1.Pod) (map[types.NamespacedName]ENIInfo, error)
+}
+
+// NewDefaultPodENIResolver constructs new defaultPodENIResolver.
+func NewDefaultPodENIInfoResolver(ec2Client services.EC2, vpcID string, logger logr.Logger) *defaultPodENIInfoResolver {
+	return &defaultPodENIInfoResolver{
+		ec2Client:                            ec2Client,
+		vpcID:                                vpcID,
+		logger:                               logger,
+		podENIInfoCache:                      cache.NewExpiring(),
+		podENIInfoCacheMutex:                 sync.RWMutex{},
+		podENIInfoCacheTTL:                   defaultPodENIInfoCacheTTL,
+		describeNetworkInterfacesIPChunkSize: describeNetworkInterfacesFiltersLimit - 1, // we used 1 filter for VPC.
+	}
+}
+
+var _ PodENIInfoResolver = &defaultPodENIInfoResolver{}
+
+// default implementation for PodENIResolver
+type defaultPodENIInfoResolver struct {
+	// ec2 client
+	ec2Client services.EC2
+	// our vpcID.
+	vpcID string
+	// logger
+	logger logr.Logger
+
+	// cache of ENIInfo by podUID(podKey + UID).
+	// NOTE: since this cache implementation will automatically GC expired entries, we don't need to GC entries.
+	podENIInfoCache *cache.Expiring
+	// podENICacheMutex protects podENICache
+	podENIInfoCacheMutex sync.RWMutex
+	// TTL for each cache entries.
+	// Note: we assume pod's ENI information(e.g. securityGroups) haven't changed per podENICacheTTL.
+	podENIInfoCacheTTL time.Duration
+
+	describeNetworkInterfacesIPChunkSize int
+}
+
+func (r *defaultPodENIInfoResolver) Resolve(ctx context.Context, pods []*corev1.Pod) (map[types.NamespacedName]ENIInfo, error) {
+	eniInfoByPodKey := r.fetchENIInfosFromCache(pods)
+
+	unresolvedPods := computePodsWithUnresolvedENIInfo(pods, eniInfoByPodKey)
+	eniInfoByPodKeyViaLookup, err := r.resolveViaCascadedLookup(ctx, unresolvedPods)
+	if err != nil {
+		return nil, err
+	}
+	if len(eniInfoByPodKeyViaLookup) > 0 {
+		r.saveENIInfosToCache(unresolvedPods, eniInfoByPodKeyViaLookup)
+		for podKey, eniInfo := range eniInfoByPodKeyViaLookup {
+			eniInfoByPodKey[podKey] = eniInfo
+		}
+		unresolvedPods = computePodsWithUnresolvedENIInfo(unresolvedPods, eniInfoByPodKeyViaLookup)
+	}
+
+	if len(unresolvedPods) > 0 {
+		unresolvedPodKeys := make([]types.NamespacedName, 0, len(unresolvedPods))
+		for _, pod := range unresolvedPods {
+			unresolvedPodKeys = append(unresolvedPodKeys, k8s.NamespacedName(pod))
+		}
+		return nil, errors.Errorf("cannot resolve pod ENI for pods: %v", unresolvedPodKeys)
+	}
+	return eniInfoByPodKey, nil
+}
+
+type podENIInfoCacheKey struct {
+	// Pod's key
+	podKey types.NamespacedName
+	// Pod's UID.
+	// Note: we assume pod's eni haven't changed as long as pod UID is same.
+	podUID string
+}
+
+func (r *defaultPodENIInfoResolver) fetchENIInfosFromCache(pods []*corev1.Pod) map[types.NamespacedName]ENIInfo {
+	r.podENIInfoCacheMutex.RLock()
+	defer r.podENIInfoCacheMutex.RUnlock()
+
+	eniInfoByPodKey := make(map[types.NamespacedName]ENIInfo)
+	for _, pod := range pods {
+		cacheKey := computePodENIInfoCacheKey(pod)
+		if rawCacheItem, exists := r.podENIInfoCache.Get(cacheKey); exists {
+			eniInfo := rawCacheItem.(ENIInfo)
+			podKey := k8s.NamespacedName(pod)
+			eniInfoByPodKey[podKey] = eniInfo
+		}
+	}
+	return eniInfoByPodKey
+}
+
+func (r *defaultPodENIInfoResolver) saveENIInfosToCache(pods []*corev1.Pod, eniInfoByPodKey map[types.NamespacedName]ENIInfo) {
+	r.podENIInfoCacheMutex.Lock()
+	defer r.podENIInfoCacheMutex.Unlock()
+
+	for _, pod := range pods {
+		podKey := k8s.NamespacedName(pod)
+		if eniInfo, exists := eniInfoByPodKey[podKey]; exists {
+			cacheKey := computePodENIInfoCacheKey(pod)
+			r.podENIInfoCache.Set(cacheKey, eniInfo, r.podENIInfoCacheTTL)
+		}
+	}
+}
+
+func (r *defaultPodENIInfoResolver) resolveViaCascadedLookup(ctx context.Context, pods []*corev1.Pod) (map[types.NamespacedName]ENIInfo, error) {
+	resolveFuncs := []func(ctx context.Context, pods []*corev1.Pod) (map[types.NamespacedName]ENIInfo, error){
+		r.resolveViaPodENIAnnotation,
+		r.resolveViaVPCIPAddress,
+		// TODO, add support for kubenet CNI plugin(kops) by resolve via routeTable.
+	}
+
+	eniInfoByPodKey := make(map[types.NamespacedName]ENIInfo)
+	for _, resolveFunc := range resolveFuncs {
+		if len(pods) == 0 {
+			break
+		}
+		resolvedENIInfoByPodKey, err := resolveFunc(ctx, pods)
+		if err != nil {
+			return nil, err
+		}
+		for podKey, eniInfo := range resolvedENIInfoByPodKey {
+			eniInfoByPodKey[podKey] = eniInfo
+		}
+		pods = computePodsWithUnresolvedENIInfo(pods, resolvedENIInfoByPodKey)
+	}
+	return eniInfoByPodKey, nil
+}
+
+// annotationSchemaPodENI is a json convertible structure that stores the Branch ENI details that can be
+// used by the CNI plugin or the component consuming the resource
+type annotationSchemaPodENI struct {
+	// ENIID is the network interface id of the branch interface
+	ENIID string `json:"eniId"`
+	// PrivateIP is the primary IP of the branch Network interface
+	PrivateIP string `json:"privateIp"`
+	// SubnetCIDR is the CIDR block of the subnet
+	SubnetCIDR string `json:"subnetCidr"`
+}
+
+// resolveViaPodENIAnnotation tries to resolve a pod ENI via the branch ENI annotation.
+func (r *defaultPodENIInfoResolver) resolveViaPodENIAnnotation(ctx context.Context, pods []*corev1.Pod) (map[types.NamespacedName]ENIInfo, error) {
+	podKeysByENIID := make(map[string][]types.NamespacedName)
+	for _, pod := range pods {
+		podENIAnnotation, ok := pod.Annotations[annotationPodENI]
+		if !ok {
+			continue
+		}
+		var schema annotationSchemaPodENI
+		if err := json.Unmarshal([]byte(podENIAnnotation), &schema); err != nil {
+			return nil, err
+		}
+		eniID := schema.ENIID
+		podKey := k8s.NamespacedName(pod)
+		podKeysByENIID[eniID] = append(podKeysByENIID[eniID], podKey)
+	}
+	if len(podKeysByENIID) == 0 {
+		return nil, nil
+	}
+
+	eniIDs := sets.StringKeySet(podKeysByENIID).List()
+	req := &ec2sdk.DescribeNetworkInterfacesInput{
+		NetworkInterfaceIds: awssdk.StringSlice(eniIDs),
+	}
+	enis, err := r.ec2Client.DescribeNetworkInterfacesAsList(ctx, req)
+	if err != nil {
+		return nil, err
+	}
+	eniInfoByPodKey := make(map[types.NamespacedName]ENIInfo)
+	for _, eni := range enis {
+		eniID := awssdk.StringValue(eni.NetworkInterfaceId)
+		eniInfo := buildENIInfoViaENI(eni)
+		for _, podKey := range podKeysByENIID[eniID] {
+			eniInfoByPodKey[podKey] = eniInfo
+		}
+	}
+	return eniInfoByPodKey, nil
+}
+
+// resolveViaVPCIPAddress tries to resolve pod ENI via the IPAddress within VPC.
+func (r *defaultPodENIInfoResolver) resolveViaVPCIPAddress(ctx context.Context, pods []*corev1.Pod) (map[types.NamespacedName]ENIInfo, error) {
+	podKeysByIP := make(map[string][]types.NamespacedName, len(pods))
+	for _, pod := range pods {
+		podKeysByIP[pod.Status.PodIP] = append(podKeysByIP[pod.Status.PodIP], k8s.NamespacedName(pod))
+	}
+	if len(podKeysByIP) == 0 {
+		return nil, nil
+	}
+
+	podIPs := sets.StringKeySet(podKeysByIP).List()
+	podIPChunks := algorithm.ChunkStrings(podIPs, r.describeNetworkInterfacesIPChunkSize)
+	eniByID := make(map[string]*ec2sdk.NetworkInterface)
+	for _, podIPChunk := range podIPChunks {
+		req := &ec2sdk.DescribeNetworkInterfacesInput{
+			Filters: []*ec2sdk.Filter{
+				{
+					Name:   awssdk.String("vpc-id"),
+					Values: awssdk.StringSlice([]string{r.vpcID}),
+				},
+				{
+					Name:   awssdk.String("addresses.private-ip-address"),
+					Values: awssdk.StringSlice(podIPChunk),
+				},
+			},
+		}
+		enis, err := r.ec2Client.DescribeNetworkInterfacesAsList(ctx, req)
+		if err != nil {
+			return nil, err
+		}
+		for _, eni := range enis {
+			eniID := awssdk.StringValue(eni.NetworkInterfaceId)
+			eniByID[eniID] = eni
+		}
+	}
+
+	eniInfoByPodKey := make(map[types.NamespacedName]ENIInfo)
+	for _, eni := range eniByID {
+		eniInfo := buildENIInfoViaENI(eni)
+		for _, addr := range eni.PrivateIpAddresses {
+			eniIP := awssdk.StringValue(addr.PrivateIpAddress)
+			for _, podKey := range podKeysByIP[eniIP] {
+				eniInfoByPodKey[podKey] = eniInfo
+			}
+		}
+	}
+	return eniInfoByPodKey, nil
+}
+
+// computePodENIInfoCacheKey computes the cacheKey for pod's ENIInfo cache.
+func computePodENIInfoCacheKey(pod *corev1.Pod) podENIInfoCacheKey {
+	return podENIInfoCacheKey{
+		podKey: k8s.NamespacedName(pod),
+		podUID: string(pod.UID),
+	}
+}
+
+// computePodsWithUnresolvedENIInfo computes pods that don't have resolvedENIInfo.
+func computePodsWithUnresolvedENIInfo(pods []*corev1.Pod, eniInfoByPodKey map[types.NamespacedName]ENIInfo) []*corev1.Pod {
+	unresolvedPods := make([]*corev1.Pod, 0, len(pods)-len(eniInfoByPodKey))
+	for _, pod := range pods {
+		podKey := k8s.NamespacedName(pod)
+		if _, ok := eniInfoByPodKey[podKey]; !ok {
+			unresolvedPods = append(unresolvedPods, pod)
+		}
+	}
+	return unresolvedPods
+}
diff --git a/pkg/networking/security_group_info.go b/pkg/networking/security_group_info.go
new file mode 100644
index 000000000..82e6b1688
--- /dev/null
+++ b/pkg/networking/security_group_info.go
@@ -0,0 +1,125 @@
+package networking
+
+import (
+	awssdk "github.com/aws/aws-sdk-go/aws"
+	ec2sdk "github.com/aws/aws-sdk-go/service/ec2"
+	"regexp"
+)
+
+const (
+	// the raw permission description
+	labelKeyRawDescription = "raw/description"
+)
+
+// SecurityGroupInfo wraps necessary information about a SecurityGroup.
+type SecurityGroupInfo struct {
+	// securityGroup's ID.
+	SecurityGroupID string
+
+	// Ingress permission for securityGroup.
+	IngressPermissions []IPPermissionInfo
+}
+
+type IPPermissionInfo struct {
+	// the aws sdk permission
+	Permission ec2sdk.IpPermission
+
+	// a set of computed labels for IPPermission.
+	// we can use labels to select the rules we want to manage.
+	Labels map[string]string
+}
+
+func buildSecurityGroupInfo(sdkSG *ec2sdk.SecurityGroup) SecurityGroupInfo {
+	sgID := awssdk.StringValue(sdkSG.GroupId)
+	var ingressPermissions []IPPermissionInfo
+	for _, sdkPermission := range sdkSG.IpPermissions {
+		for _, expandedPermission := range expandSDKIPPermission(*sdkPermission) {
+			ingressPermissions = append(ingressPermissions, buildIPPermissionInfo(expandedPermission))
+		}
+	}
+	return SecurityGroupInfo{
+		SecurityGroupID:    sgID,
+		IngressPermissions: ingressPermissions,
+	}
+}
+
+func buildIPPermissionInfo(sdkPermission ec2sdk.IpPermission) IPPermissionInfo {
+	if len(sdkPermission.IpRanges) == 1 {
+		return IPPermissionInfo{
+			Permission: sdkPermission,
+			Labels:     buildIPPermissionLabelForDescription(awssdk.StringValue(sdkPermission.IpRanges[0].Description)),
+		}
+	}
+	if len(sdkPermission.Ipv6Ranges) == 1 {
+		return IPPermissionInfo{
+			Permission: sdkPermission,
+			Labels:     buildIPPermissionLabelForDescription(awssdk.StringValue(sdkPermission.Ipv6Ranges[0].Description)),
+		}
+	}
+	if len(sdkPermission.PrefixListIds) == 1 {
+		return IPPermissionInfo{
+			Permission: sdkPermission,
+			Labels:     buildIPPermissionLabelForDescription(awssdk.StringValue(sdkPermission.PrefixListIds[0].Description)),
+		}
+	}
+	if len(sdkPermission.UserIdGroupPairs) == 1 {
+		return IPPermissionInfo{
+			Permission: sdkPermission,
+			Labels:     buildIPPermissionLabelForDescription(awssdk.StringValue(sdkPermission.UserIdGroupPairs[0].Description)),
+		}
+	}
+	return IPPermissionInfo{
+		Permission: sdkPermission,
+		Labels:     nil,
+	}
+}
+
+// expandSDKIPPermission will expand the IPPermission so that each permission only contain single entry.
+// EC2 api automatically group IPPermissions, so we need to expand first before further processing.
+func expandSDKIPPermission(sdkPermission ec2sdk.IpPermission) []ec2sdk.IpPermission {
+	var expandedPermissions []ec2sdk.IpPermission
+	base := ec2sdk.IpPermission{
+		FromPort:   sdkPermission.FromPort,
+		ToPort:     sdkPermission.ToPort,
+		IpProtocol: sdkPermission.IpProtocol,
+	}
+
+	for _, ipRange := range sdkPermission.IpRanges {
+		perm := base
+		perm.IpRanges = []*ec2sdk.IpRange{ipRange}
+		expandedPermissions = append(expandedPermissions, perm)
+	}
+
+	for _, ipRange := range sdkPermission.Ipv6Ranges {
+		perm := base
+		perm.Ipv6Ranges = []*ec2sdk.Ipv6Range{ipRange}
+		expandedPermissions = append(expandedPermissions, perm)
+	}
+	for _, prefixListID := range sdkPermission.PrefixListIds {
+		perm := base
+		perm.PrefixListIds = []*ec2sdk.PrefixListId{prefixListID}
+		expandedPermissions = append(expandedPermissions, perm)
+	}
+
+	for _, ug := range sdkPermission.UserIdGroupPairs {
+		perm := base
+		perm.UserIdGroupPairs = []*ec2sdk.UserIdGroupPair{ug}
+		expandedPermissions = append(expandedPermissions, perm)
+	}
+
+	if len(expandedPermissions) == 0 {
+		expandedPermissions = append(expandedPermissions, sdkPermission)
+	}
+	return expandedPermissions
+}
+
+var commaSeparatedKVPairPattern = regexp.MustCompile(`(?P<key>[^\s,=]+)=(?P<value>[^\s,=]+)(?:,|$)`)
+
+// buildIPPermissionLabelForDescription constructs a set of labels parsed from IPPermission description
+func buildIPPermissionLabelForDescription(description string) map[string]string {
+	labels := map[string]string{labelKeyRawDescription: description}
+	for _, groups := range commaSeparatedKVPairPattern.FindAllStringSubmatch(description, -1) {
+		labels[groups[1]] = groups[2]
+	}
+	return labels
+}
diff --git a/pkg/networking/security_group_reconciler.go b/pkg/networking/security_group_reconciler.go
new file mode 100644
index 000000000..9cb9232ac
--- /dev/null
+++ b/pkg/networking/security_group_reconciler.go
@@ -0,0 +1,75 @@
+package networking
+
+import (
+	"context"
+	awssdk "github.com/aws/aws-sdk-go/aws"
+	ec2sdk "github.com/aws/aws-sdk-go/service/ec2"
+	"github.com/go-logr/logr"
+	"k8s.io/apimachinery/pkg/labels"
+	"k8s.io/apimachinery/pkg/util/cache"
+	"sigs.k8s.io/aws-alb-ingress-controller/pkg/aws/services"
+	"sync"
+	"time"
+)
+
+const (
+	defaultSGInfoCacheTTL = 10 * time.Minute
+)
+
+type SecurityGroupReconcileOptions struct {
+	// PermissionSelector defines the selector to identify permissions that should be managed.
+	// By default, it selects every permission.
+	PermissionSelector labels.Selector
+}
+
+type SecurityGroupReconcileOption func(opts *SecurityGroupReconcileOptions)
+
+// SecurityGroupReconciler manages securityGroup rules on securityGroup.
+type SecurityGroupReconciler interface {
+	ReconcileIngress(ctx context.Context, sgID string, permissions []IPPermissionInfo, opts ...SecurityGroupReconcileOption) error
+}
+
+// NewDefaultSecurityGroupReconciler constructs new defaultSecurityGroupReconciler.
+func NewDefaultSecurityGroupReconciler(ec2Client services.EC2, logger logr.Logger) *defaultSecurityGroupReconciler {
+	return &defaultSecurityGroupReconciler{
+		ec2Client:        ec2Client,
+		logger:           logger,
+		sgInfoCache:      cache.NewExpiring(),
+		sgInfoCacheMutex: sync.RWMutex{},
+		sgInfoCacheTTL:   defaultSGInfoCacheTTL,
+	}
+}
+
+var _ SecurityGroupReconciler = &defaultSecurityGroupReconciler{}
+
+// default implementation for SecurityGroupReconciler.
+type defaultSecurityGroupReconciler struct {
+	ec2Client services.EC2
+	logger    logr.Logger
+
+	sgInfoCache      *cache.Expiring
+	sgInfoCacheMutex sync.RWMutex
+	sgInfoCacheTTL   time.Duration
+}
+
+func (r *defaultSecurityGroupReconciler) ReconcileIngress(ctx context.Context, sgID string, permissions []IPPermissionInfo, opts ...SecurityGroupReconcileOption) error {
+	// TODO
+	return nil
+}
+
+func (r *defaultSecurityGroupReconciler) fetchSGInfosFromAWS(ctx context.Context, sgIDs []string) (map[string]SecurityGroupInfo, error) {
+	req := &ec2sdk.DescribeSecurityGroupsInput{
+		GroupIds: awssdk.StringSlice(sgIDs),
+	}
+	sgs, err := r.ec2Client.DescribeSecurityGroupsAsList(ctx, req)
+	if err != nil {
+		return nil, err
+	}
+	sgInfoByID := make(map[string]SecurityGroupInfo, len(sgs))
+	for _, sg := range sgs {
+		sgID := awssdk.StringValue(sg.GroupId)
+		sgInfo := buildSecurityGroupInfo(sg)
+		sgInfoByID[sgID] = sgInfo
+	}
+	return sgInfoByID, nil
+}


Test patch:
diff --git a/pkg/backend/endpoint_resolver_test.go b/pkg/backend/endpoint_resolver_test.go
index fd66d1568..280fff99c 100644
--- a/pkg/backend/endpoint_resolver_test.go
+++ b/pkg/backend/endpoint_resolver_test.go
@@ -854,55 +854,3 @@ func Test_isPodMeetCriteria(t *testing.T) {
 		})
 	}
 }
-
-func Test_extractNodeInstanceID(t *testing.T) {
-	type args struct {
-		node *corev1.Node
-	}
-	tests := []struct {
-		name    string
-		args    args
-		want    string
-		wantErr error
-	}{
-		{
-			name: "node without providerID",
-			args: args{
-				node: &corev1.Node{
-					ObjectMeta: metav1.ObjectMeta{
-						Name: "my-node-name",
-					},
-					Spec: corev1.NodeSpec{
-						ProviderID: "",
-					},
-				},
-			},
-			wantErr: errors.New("providerID is not specified for node: my-node-name"),
-		},
-		{
-			name: "node with providerID",
-			args: args{
-				node: &corev1.Node{
-					ObjectMeta: metav1.ObjectMeta{
-						Name: "my-node-name",
-					},
-					Spec: corev1.NodeSpec{
-						ProviderID: "aws:///us-west-2b/i-abcdefg0",
-					},
-				},
-			},
-			want: "i-abcdefg0",
-		},
-	}
-	for _, tt := range tests {
-		t.Run(tt.name, func(t *testing.T) {
-			got, err := extractNodeInstanceID(tt.args.node)
-			if tt.wantErr != nil {
-				assert.EqualError(t, err, tt.wantErr.Error())
-			} else {
-				assert.NoError(t, err)
-				assert.Equal(t, tt.want, got)
-			}
-		})
-	}
-}
diff --git a/pkg/equality/filter_field_test.go b/pkg/equality/filter_field_test.go
new file mode 100644
index 000000000..44401dc4e
--- /dev/null
+++ b/pkg/equality/filter_field_test.go
@@ -0,0 +1,129 @@
+package equality
+
+import (
+	"github.com/google/go-cmp/cmp"
+	"github.com/stretchr/testify/assert"
+	"testing"
+)
+
+func TestFilterField(t *testing.T) {
+	type testStruct struct {
+		SliceField0 []string
+		SliceField1 []string
+	}
+	type args struct {
+		field string
+		opt   cmp.Option
+	}
+	tests := []struct {
+		name       string
+		args       args
+		argLeft    testStruct
+		argRight   testStruct
+		wantEquals bool
+	}{
+		{
+			name: "both sliceField equals",
+			args: args{
+				field: "SliceField0",
+				opt:   IgnoreStringSliceOrder(),
+			},
+			argLeft: testStruct{
+				SliceField0: []string{"A", "B", "C"},
+				SliceField1: []string{"1", "2", "3"},
+			},
+			argRight: testStruct{
+				SliceField0: []string{"A", "B", "C"},
+				SliceField1: []string{"1", "2", "3"},
+			},
+			wantEquals: true,
+		},
+		{
+			name: "SliceField0 compares without order - equals",
+			args: args{
+				field: "SliceField0",
+				opt:   IgnoreStringSliceOrder(),
+			},
+			argLeft: testStruct{
+				SliceField0: []string{"A", "B", "C"},
+				SliceField1: []string{"1", "2", "3"},
+			},
+			argRight: testStruct{
+				SliceField0: []string{"C", "B", "A"},
+				SliceField1: []string{"1", "2", "3"},
+			},
+			wantEquals: true,
+		},
+		{
+			name: "SliceField0 compares without order - not equals",
+			args: args{
+				field: "SliceField0",
+				opt:   IgnoreStringSliceOrder(),
+			},
+			argLeft: testStruct{
+				SliceField0: []string{"A", "B", "C"},
+				SliceField1: []string{"1", "2", "3"},
+			},
+			argRight: testStruct{
+				SliceField0: []string{"A", "B", "D"},
+				SliceField1: []string{"1", "2", "3"},
+			},
+			wantEquals: false,
+		},
+		{
+			name: "SliceField0 compares without order - slice1 not equals",
+			args: args{
+				field: "SliceField0",
+				opt:   IgnoreStringSliceOrder(),
+			},
+			argLeft: testStruct{
+				SliceField0: []string{"A", "B", "C"},
+				SliceField1: []string{"1", "2", "3"},
+			},
+			argRight: testStruct{
+				SliceField0: []string{"A", "B", "C"},
+				SliceField1: []string{"3", "2", "1"},
+			},
+			wantEquals: false,
+		},
+		{
+			name: "SliceField1 compares without order - equals",
+			args: args{
+				field: "SliceField1",
+				opt:   IgnoreStringSliceOrder(),
+			},
+			argLeft: testStruct{
+				SliceField0: []string{"A", "B", "C"},
+				SliceField1: []string{"1", "2", "3"},
+			},
+			argRight: testStruct{
+				SliceField0: []string{"A", "B", "C"},
+				SliceField1: []string{"3", "2", "1"},
+			},
+			wantEquals: true,
+		},
+		{
+			name: "SliceField1 compares without order - not equals",
+			args: args{
+				field: "SliceField1",
+				opt:   IgnoreStringSliceOrder(),
+			},
+			argLeft: testStruct{
+				SliceField0: []string{"A", "B", "C"},
+				SliceField1: []string{"1", "2", "3"},
+			},
+			argRight: testStruct{
+				SliceField0: []string{"A", "B", "C"},
+				SliceField1: []string{"1", "2", "4"},
+			},
+			wantEquals: false,
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			opts := FilterField(testStruct{}, tt.args.field, tt.args.opt)
+			gotEquals := cmp.Equal(tt.argLeft, tt.argRight, opts)
+			assert.Equal(t, tt.wantEquals, gotEquals)
+		})
+	}
+}
diff --git a/pkg/equality/ignore_string_slice_order_test.go b/pkg/equality/ignore_string_slice_order_test.go
new file mode 100644
index 000000000..76b10ee61
--- /dev/null
+++ b/pkg/equality/ignore_string_slice_order_test.go
@@ -0,0 +1,58 @@
+package equality
+
+import (
+	"github.com/google/go-cmp/cmp"
+	"github.com/stretchr/testify/assert"
+	"testing"
+)
+
+func TestIgnoreStringSliceOrder(t *testing.T) {
+	type testStruct struct {
+		SliceField []string
+	}
+
+	tests := []struct {
+		name       string
+		argLeft    testStruct
+		argRight   testStruct
+		wantEquals bool
+	}{
+		{
+			name: "two string slice equals",
+			argLeft: testStruct{
+				SliceField: []string{"A", "B", "C"},
+			},
+			argRight: testStruct{
+				SliceField: []string{"A", "B", "C"},
+			},
+			wantEquals: true,
+		},
+		{
+			name: "two string slice equals without order",
+			argLeft: testStruct{
+				SliceField: []string{"A", "B", "C"},
+			},
+			argRight: testStruct{
+				SliceField: []string{"C", "B", "A"},
+			},
+			wantEquals: true,
+		},
+		{
+			name: "two string slice not equals",
+			argLeft: testStruct{
+				SliceField: []string{"A", "B", "C"},
+			},
+			argRight: testStruct{
+				SliceField: []string{"A", "B", "D"},
+			},
+			wantEquals: false,
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			opts := IgnoreStringSliceOrder()
+			gotEquals := cmp.Equal(tt.argLeft, tt.argRight, opts)
+			assert.Equal(t, tt.wantEquals, gotEquals)
+		})
+	}
+}
diff --git a/pkg/k8s/node_utils_test.go b/pkg/k8s/node_utils_test.go
index 39fb6411a..ab0f68280 100644
--- a/pkg/k8s/node_utils_test.go
+++ b/pkg/k8s/node_utils_test.go
@@ -1,8 +1,10 @@
 package k8s
 
 import (
+	"github.com/pkg/errors"
 	"github.com/stretchr/testify/assert"
 	corev1 "k8s.io/api/core/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	"testing"
 )
 
@@ -122,3 +124,55 @@ func TestGetNodeCondition(t *testing.T) {
 		})
 	}
 }
+
+func TestExtractNodeInstanceID(t *testing.T) {
+	type args struct {
+		node *corev1.Node
+	}
+	tests := []struct {
+		name    string
+		args    args
+		want    string
+		wantErr error
+	}{
+		{
+			name: "node without providerID",
+			args: args{
+				node: &corev1.Node{
+					ObjectMeta: metav1.ObjectMeta{
+						Name: "my-node-name",
+					},
+					Spec: corev1.NodeSpec{
+						ProviderID: "",
+					},
+				},
+			},
+			wantErr: errors.New("providerID is not specified for node: my-node-name"),
+		},
+		{
+			name: "node with providerID",
+			args: args{
+				node: &corev1.Node{
+					ObjectMeta: metav1.ObjectMeta{
+						Name: "my-node-name",
+					},
+					Spec: corev1.NodeSpec{
+						ProviderID: "aws:///us-west-2b/i-abcdefg0",
+					},
+				},
+			},
+			want: "i-abcdefg0",
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			got, err := ExtractNodeInstanceID(tt.args.node)
+			if tt.wantErr != nil {
+				assert.EqualError(t, err, tt.wantErr.Error())
+			} else {
+				assert.NoError(t, err)
+				assert.Equal(t, tt.want, got)
+			}
+		})
+	}
+}
diff --git a/pkg/networking/security_group_info_test.go b/pkg/networking/security_group_info_test.go
new file mode 100644
index 000000000..2b7e1ae62
--- /dev/null
+++ b/pkg/networking/security_group_info_test.go
@@ -0,0 +1,63 @@
+package networking
+
+import (
+	"github.com/stretchr/testify/assert"
+	"testing"
+)
+
+func Test_buildIPPermissionLabelForDescription(t *testing.T) {
+	type args struct {
+		description string
+	}
+	tests := []struct {
+		name string
+		args args
+		want map[string]string
+	}{
+		{
+			name: "empty description",
+			args: args{
+				description: "",
+			},
+			want: map[string]string{
+				labelKeyRawDescription: "",
+			},
+		},
+		{
+			name: "non-empty description",
+			args: args{
+				description: "some-description",
+			},
+			want: map[string]string{
+				labelKeyRawDescription: "some-description",
+			},
+		},
+		{
+			name: "single k-v pair description",
+			args: args{
+				description: "key1=value1",
+			},
+			want: map[string]string{
+				labelKeyRawDescription: "key1=value1",
+				"key1":                 "value1",
+			},
+		},
+		{
+			name: "multiple k-v pair description",
+			args: args{
+				description: "key1=value1,key2=value2",
+			},
+			want: map[string]string{
+				labelKeyRawDescription: "key1=value1,key2=value2",
+				"key1":                 "value1",
+				"key2":                 "value2",
+			},
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			got := buildIPPermissionLabelForDescription(tt.args.description)
+			assert.Equal(t, tt.want, got)
+		})
+	}
+}