linkerd2/controller/api/destination/external-workload/endpoints_controller_test.go

1389 lines
39 KiB
Go

package externalworkload
import (
"context"
"fmt"
"reflect"
"sort"
"testing"
"time"
ewv1beta1 "github.com/linkerd/linkerd2/controller/gen/apis/externalworkload/v1beta1"
"github.com/linkerd/linkerd2/controller/k8s"
v1 "k8s.io/api/core/v1"
discoveryv1 "k8s.io/api/discovery/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apimachinery/pkg/types"
"k8s.io/apimachinery/pkg/util/intstr"
k8stesting "k8s.io/client-go/testing"
"k8s.io/client-go/tools/cache"
"k8s.io/utils/ptr"
)
type endpointSliceController struct {
*EndpointsController
endpointSliceStore cache.Store
externalWorkloadsStore cache.Store
serviceStore cache.Store
}
func newController(t *testing.T) (*k8s.API, func() []k8stesting.Action, *endpointSliceController) {
t.Helper()
k8sAPI, actions, err := k8s.NewFakeAPIWithActions()
if err != nil {
t.Fatalf("unexpected error %v", err)
}
esController, err := NewEndpointsController(k8sAPI, "hostname", "linkerd", make(chan struct{}), false)
if err != nil {
t.Fatalf("unexpected error %v", err)
}
return k8sAPI, actions, &endpointSliceController{
esController,
k8sAPI.ES().Informer().GetStore(),
k8sAPI.ExtWorkload().Informer().GetStore(),
k8sAPI.Svc().Informer().GetStore(),
}
}
func newExternalWorkload(n int, namespace string, ready bool, terminating bool) *ewv1beta1.ExternalWorkload {
status := ewv1beta1.ConditionTrue
if !ready {
status = ewv1beta1.ConditionFalse
}
var deletionTimestamp *metav1.Time
if terminating {
deletionTimestamp = &metav1.Time{
Time: time.Now(),
}
}
ew := &ewv1beta1.ExternalWorkload{
TypeMeta: metav1.TypeMeta{APIVersion: "v1"},
ObjectMeta: metav1.ObjectMeta{
Namespace: namespace,
Name: fmt.Sprintf("ew-%d", n),
Labels: map[string]string{"foo": "bar"},
DeletionTimestamp: deletionTimestamp,
ResourceVersion: fmt.Sprint(n),
},
Spec: ewv1beta1.ExternalWorkloadSpec{
Ports: []ewv1beta1.PortSpec{
{
Name: "name",
Port: 444,
},
},
WorkloadIPs: []ewv1beta1.WorkloadIP{
{Ip: "1.2.3.4"},
},
},
Status: ewv1beta1.ExternalWorkloadStatus{
Conditions: []ewv1beta1.WorkloadCondition{
{
Type: ewv1beta1.WorkloadReady,
Status: status,
},
},
},
}
return ew
}
// Ensure SyncService for service with no selector results in no action
func TestSyncServiceNoSelector(t *testing.T) {
ns := metav1.NamespaceDefault
serviceName := "testing-1"
_, actions, esController := newController(t)
esController.serviceStore.Add(&v1.Service{
ObjectMeta: metav1.ObjectMeta{Name: serviceName, Namespace: ns},
Spec: v1.ServiceSpec{
Ports: []v1.ServicePort{{TargetPort: intstr.FromInt32(80)}},
},
})
err := esController.syncService(fmt.Sprintf("%s/%s", ns, serviceName))
if err != nil {
t.Errorf("unexpected error: %s", err)
}
if len(actions()) != 0 {
t.Errorf("expected 0 actions, got: %d", len(actions()))
}
}
func TestServiceExternalNameTypeSync(t *testing.T) {
serviceName := "testing-1"
namespace := "zahari"
testCases := []struct {
desc string
service *v1.Service
}{
{
desc: "External name with selector and ports should not receive endpoint slices",
service: &v1.Service{
ObjectMeta: metav1.ObjectMeta{Name: serviceName, Namespace: namespace},
Spec: v1.ServiceSpec{
Selector: map[string]string{"foo": "bar"},
Ports: []v1.ServicePort{{Port: 80}},
Type: v1.ServiceTypeExternalName,
},
},
},
{
desc: "External name with ports should not receive endpoint slices",
service: &v1.Service{
ObjectMeta: metav1.ObjectMeta{Name: serviceName, Namespace: namespace},
Spec: v1.ServiceSpec{
Ports: []v1.ServicePort{{Port: 80}},
Type: v1.ServiceTypeExternalName,
},
},
},
{
desc: "External name with selector should not receive endpoint slices",
service: &v1.Service{
ObjectMeta: metav1.ObjectMeta{Name: serviceName, Namespace: namespace},
Spec: v1.ServiceSpec{
Selector: map[string]string{"foo": "bar"},
Type: v1.ServiceTypeExternalName,
},
},
},
{
desc: "External name without selector and ports should not receive endpoint slices",
service: &v1.Service{
ObjectMeta: metav1.ObjectMeta{Name: serviceName, Namespace: namespace},
Spec: v1.ServiceSpec{
Type: v1.ServiceTypeExternalName,
},
},
},
}
for _, tc := range testCases {
t.Run(tc.desc, func(t *testing.T) {
client, actions, esController := newController(t)
ew := newExternalWorkload(1, namespace, true, false)
err := esController.externalWorkloadsStore.Add(ew)
if err != nil {
t.Errorf("unexpected error: %s", err)
}
err = esController.serviceStore.Add(tc.service)
if err != nil {
t.Errorf("unexpected error: %s", err)
}
err = esController.syncService(fmt.Sprintf("%s/%s", namespace, serviceName))
if err != nil {
t.Errorf("unexpected error: %s", err)
}
if len(actions()) != 0 {
t.Errorf("expected 0 actions, got: %d", len(actions()))
}
sliceList, err := client.Client.DiscoveryV1().EndpointSlices(namespace).List(context.TODO(), metav1.ListOptions{})
if err != nil {
t.Errorf("unexpected error: %s", err)
}
if len(sliceList.Items) != 0 {
t.Errorf("Expected 0 endpoint slices, got: %d", len(sliceList.Items))
}
})
}
}
// Ensure SyncService for service with pending deletion results in no action
func TestSyncServicePendingDeletion(t *testing.T) {
ns := metav1.NamespaceDefault
serviceName := "testing-1"
deletionTimestamp := metav1.Now()
_, actions, esController := newController(t)
esController.serviceStore.Add(&v1.Service{
ObjectMeta: metav1.ObjectMeta{Name: serviceName, Namespace: ns, DeletionTimestamp: &deletionTimestamp},
Spec: v1.ServiceSpec{
Selector: map[string]string{"foo": "bar"},
Ports: []v1.ServicePort{{TargetPort: intstr.FromInt32(80)}},
},
})
err := esController.syncService(fmt.Sprintf("%s/%s", ns, serviceName))
if err != nil {
t.Errorf("unexpected error: %s", err)
}
if len(actions()) != 0 {
t.Errorf("Expected 0 actions, got: %d", len(actions()))
}
}
// Ensure SyncService correctly selects ExternalWorkload.
func TestSyncServiceExternalWorkloadSelection(t *testing.T) {
client, actions, esController := newController(t)
ns := "test-ns"
ew1 := newExternalWorkload(1, ns, true, false)
esController.externalWorkloadsStore.Add(ew1)
// ensure this ew will not match the selector
ew2 := newExternalWorkload(2, ns, true, false)
ew2.Labels["foo"] = "boo"
esController.externalWorkloadsStore.Add(ew2)
standardSyncService(t, esController, ns, "testing-1")
expectActions(t, actions(), 1, "create", "endpointslices")
// an endpoint slice should be created, it should only reference ew1 (not ew2)
slices, err := client.Client.DiscoveryV1().EndpointSlices(ns).List(context.TODO(), metav1.ListOptions{})
if err != nil {
t.Errorf("Expected no error fetching endpoint slices, got: %s", err)
}
if len(slices.Items) != 1 {
t.Errorf("Expected 1 endpoint slices, got: %d", len(slices.Items))
}
slice := slices.Items[0]
if len(slice.Endpoints) != 1 {
t.Errorf("Expected 1 endpoint in first slice, got: %d", len(slice.Endpoints))
}
endpoint := slice.Endpoints[0]
if endpoint.TargetRef.Kind != "ExternalWorkload" || endpoint.TargetRef.Namespace != ns || endpoint.TargetRef.Name != ew1.Name {
t.Errorf("Expected endpoint to target ExternalWorkload")
}
}
func TestSyncServiceEndpointSlicePendingDeletion(t *testing.T) {
client, actions, esController := newController(t)
ns := "test-ns"
serviceName := "testing-1"
service := createService(t, esController, ns, serviceName)
err := esController.syncService(fmt.Sprintf("%s/%s", ns, serviceName))
if err != nil {
t.Fatalf("Expected no error creating EndpointSlice: %v", err)
}
gvk := schema.GroupVersionKind{Version: "v1", Kind: "Service"}
ownerRef := metav1.NewControllerRef(service, gvk)
deletedTs := metav1.Now()
endpointSlice := &discoveryv1.EndpointSlice{
ObjectMeta: metav1.ObjectMeta{
Name: "epSlice-1",
Namespace: ns,
OwnerReferences: []metav1.OwnerReference{*ownerRef},
Labels: map[string]string{
discoveryv1.LabelServiceName: serviceName,
discoveryv1.LabelManagedBy: managedBy,
},
DeletionTimestamp: &deletedTs,
},
AddressType: discoveryv1.AddressTypeIPv4,
}
err = esController.endpointSliceStore.Add(endpointSlice)
if err != nil {
t.Fatalf("Expected no error adding EndpointSlice: %v", err)
}
_, err = client.Client.DiscoveryV1().EndpointSlices(ns).Create(context.TODO(), endpointSlice, metav1.CreateOptions{})
if err != nil {
t.Fatalf("Expected no error creating EndpointSlice: %v", err)
}
numActionsBefore := len(actions())
err = esController.syncService(fmt.Sprintf("%s/%s", ns, serviceName))
if err != nil {
t.Errorf("Expected no error syncing service, got: %s", err)
}
// The EndpointSlice marked for deletion should be ignored by the controller, and thus
// should not result in any action.
if len(actions()) != numActionsBefore {
t.Errorf("Expected 0 more actions, got %d", len(actions())-numActionsBefore)
}
}
func makeExternalWorkload(resVersion, name string, labels map[string]string, ports map[int32]string, ips []string) *ewv1beta1.ExternalWorkload {
portSpecs := []ewv1beta1.PortSpec{}
for port, name := range ports {
spec := ewv1beta1.PortSpec{
Port: port,
}
if name != "" {
spec.Name = name
}
portSpecs = append(portSpecs, spec)
}
wIps := []ewv1beta1.WorkloadIP{}
for _, ip := range ips {
wIps = append(wIps, ewv1beta1.WorkloadIP{Ip: ip})
}
ew := &ewv1beta1.ExternalWorkload{
ObjectMeta: metav1.ObjectMeta{
Name: name,
Namespace: "ns",
Labels: labels,
ResourceVersion: resVersion,
},
Spec: ewv1beta1.ExternalWorkloadSpec{
MeshTLS: ewv1beta1.MeshTLS{
Identity: "some-identity",
ServerName: "some-sni",
},
Ports: portSpecs,
WorkloadIPs: wIps,
},
Status: ewv1beta1.ExternalWorkloadStatus{
Conditions: []ewv1beta1.WorkloadCondition{
{
Type: ewv1beta1.WorkloadReady,
Status: ewv1beta1.ConditionTrue,
},
},
},
}
ew.ObjectMeta.UID = types.UID(fmt.Sprintf("%s-%s", ew.Namespace, ew.Name))
return ew
}
func TestSyncService(t *testing.T) {
creationTimestamp := metav1.Now()
namespace := "test-ns"
testcases := []struct {
name string
service *v1.Service
externalWorkloads []*ewv1beta1.ExternalWorkload
expectedEndpointPorts []discoveryv1.EndpointPort
expectedEndpoints []discoveryv1.Endpoint
}{
{
name: "EW with multiple IPs and Service with ipFamilies=ipv4",
service: &v1.Service{
ObjectMeta: metav1.ObjectMeta{
Name: "foobar",
Namespace: namespace,
CreationTimestamp: creationTimestamp,
},
Spec: v1.ServiceSpec{
Ports: []v1.ServicePort{
{Name: "tcp-example", TargetPort: intstr.FromInt32(80), Protocol: v1.ProtocolTCP},
{Name: "udp-example", TargetPort: intstr.FromInt32(161), Protocol: v1.ProtocolUDP},
{Name: "sctp-example", TargetPort: intstr.FromInt32(3456), Protocol: v1.ProtocolSCTP},
},
Selector: map[string]string{"foo": "bar"},
IPFamilies: []v1.IPFamily{v1.IPv4Protocol},
},
},
externalWorkloads: []*ewv1beta1.ExternalWorkload{
{
ObjectMeta: metav1.ObjectMeta{
Namespace: namespace,
Name: "ew0",
Labels: map[string]string{"foo": "bar"},
DeletionTimestamp: nil,
},
Spec: ewv1beta1.ExternalWorkloadSpec{
WorkloadIPs: []ewv1beta1.WorkloadIP{
{
Ip: "10.0.0.1",
},
},
Ports: []ewv1beta1.PortSpec{
{Name: "tcp-example", Port: 80, Protocol: v1.ProtocolTCP},
{Name: "udp-example", Port: 161, Protocol: v1.ProtocolUDP},
{Name: "sctp-example", Port: 3456, Protocol: v1.ProtocolSCTP},
},
},
Status: ewv1beta1.ExternalWorkloadStatus{
Conditions: []ewv1beta1.WorkloadCondition{
{
Type: ewv1beta1.WorkloadReady,
Status: ewv1beta1.ConditionTrue,
},
},
},
},
{
ObjectMeta: metav1.ObjectMeta{
Namespace: namespace,
Name: "ew1",
Labels: map[string]string{"foo": "bar"},
DeletionTimestamp: nil,
},
Spec: ewv1beta1.ExternalWorkloadSpec{
WorkloadIPs: []ewv1beta1.WorkloadIP{
{
Ip: "10.0.0.2",
},
{
Ip: "fd08::5678:0000:0000:9abc:def0",
},
},
Ports: []ewv1beta1.PortSpec{
{Name: "tcp-example", Port: 80, Protocol: v1.ProtocolTCP},
{Name: "udp-example", Port: 161, Protocol: v1.ProtocolUDP},
{Name: "sctp-example", Port: 3456, Protocol: v1.ProtocolSCTP},
},
},
Status: ewv1beta1.ExternalWorkloadStatus{
Conditions: []ewv1beta1.WorkloadCondition{
{
Type: ewv1beta1.WorkloadReady,
Status: ewv1beta1.ConditionTrue,
},
},
},
},
},
expectedEndpointPorts: []discoveryv1.EndpointPort{
{
Name: ptr.To("udp-example"),
Protocol: protoPtr(v1.ProtocolUDP),
Port: ptr.To(int32(161)),
},
{
Name: ptr.To("sctp-example"),
Protocol: protoPtr(v1.ProtocolSCTP),
Port: ptr.To(int32(3456)),
},
{
Name: ptr.To("tcp-example"),
Protocol: protoPtr(v1.ProtocolTCP),
Port: ptr.To(int32(80)),
},
},
expectedEndpoints: []discoveryv1.Endpoint{
{
Conditions: discoveryv1.EndpointConditions{
Ready: ptr.To(true),
Serving: ptr.To(true),
Terminating: ptr.To(false),
},
Addresses: []string{"10.0.0.1"},
TargetRef: &v1.ObjectReference{Kind: "ExternalWorkload", Namespace: namespace, Name: "ew0"},
},
{
Conditions: discoveryv1.EndpointConditions{
Ready: ptr.To(true),
Serving: ptr.To(true),
Terminating: ptr.To(false),
},
Addresses: []string{"10.0.0.2"},
TargetRef: &v1.ObjectReference{Kind: "ExternalWorkload", Namespace: namespace, Name: "ew1"},
},
},
},
{
name: "EWs with multiple IPs and Service with ipFamilies=ipv6",
service: &v1.Service{
ObjectMeta: metav1.ObjectMeta{
Name: "foobar",
Namespace: namespace,
CreationTimestamp: creationTimestamp,
},
Spec: v1.ServiceSpec{
Ports: []v1.ServicePort{
{Name: "tcp-example", TargetPort: intstr.FromInt32(80), Protocol: v1.ProtocolTCP},
{Name: "udp-example", TargetPort: intstr.FromInt32(161), Protocol: v1.ProtocolUDP},
{Name: "sctp-example", TargetPort: intstr.FromInt32(3456), Protocol: v1.ProtocolSCTP},
},
Selector: map[string]string{"foo": "bar"},
IPFamilies: []v1.IPFamily{v1.IPv6Protocol},
},
},
externalWorkloads: []*ewv1beta1.ExternalWorkload{
{
ObjectMeta: metav1.ObjectMeta{
Namespace: namespace,
Name: "ew0",
Labels: map[string]string{"foo": "bar"},
DeletionTimestamp: nil,
},
Spec: ewv1beta1.ExternalWorkloadSpec{
WorkloadIPs: []ewv1beta1.WorkloadIP{
{
Ip: "10.0.0.1",
},
},
Ports: []ewv1beta1.PortSpec{
{Name: "tcp-example", Port: 80, Protocol: v1.ProtocolTCP},
{Name: "udp-example", Port: 161, Protocol: v1.ProtocolUDP},
{Name: "sctp-example", Port: 3456, Protocol: v1.ProtocolSCTP},
},
},
Status: ewv1beta1.ExternalWorkloadStatus{
Conditions: []ewv1beta1.WorkloadCondition{
{
Type: ewv1beta1.WorkloadReady,
Status: ewv1beta1.ConditionTrue,
},
},
},
},
{
ObjectMeta: metav1.ObjectMeta{
Namespace: namespace,
Name: "ew1",
Labels: map[string]string{"foo": "bar"},
DeletionTimestamp: nil,
},
Spec: ewv1beta1.ExternalWorkloadSpec{
WorkloadIPs: []ewv1beta1.WorkloadIP{
{
Ip: "10.0.0.2",
},
{
Ip: "fd08::5678:0000:0000:9abc:def0",
},
},
Ports: []ewv1beta1.PortSpec{
{Name: "tcp-example", Port: 80, Protocol: v1.ProtocolTCP},
{Name: "udp-example", Port: 161, Protocol: v1.ProtocolUDP},
{Name: "sctp-example", Port: 3456, Protocol: v1.ProtocolSCTP},
},
},
Status: ewv1beta1.ExternalWorkloadStatus{
Conditions: []ewv1beta1.WorkloadCondition{
{
Type: ewv1beta1.WorkloadReady,
Status: ewv1beta1.ConditionTrue,
},
},
},
},
},
expectedEndpointPorts: []discoveryv1.EndpointPort{
{
Name: ptr.To("udp-example"),
Protocol: protoPtr(v1.ProtocolUDP),
Port: ptr.To(int32(161)),
},
{
Name: ptr.To("sctp-example"),
Protocol: protoPtr(v1.ProtocolSCTP),
Port: ptr.To(int32(3456)),
},
{
Name: ptr.To("tcp-example"),
Protocol: protoPtr(v1.ProtocolTCP),
Port: ptr.To(int32(80)),
},
},
expectedEndpoints: []discoveryv1.Endpoint{
{
Conditions: discoveryv1.EndpointConditions{
Ready: ptr.To(true),
Serving: ptr.To(true),
Terminating: ptr.To(false),
},
Addresses: []string{"fd08::5678:0000:0000:9abc:def0"},
TargetRef: &v1.ObjectReference{Kind: "ExternalWorkload", Namespace: namespace, Name: "ew1"},
},
},
},
{
name: "Not ready workloads",
service: &v1.Service{
ObjectMeta: metav1.ObjectMeta{
Name: "foobar",
Namespace: namespace,
CreationTimestamp: creationTimestamp,
},
Spec: v1.ServiceSpec{
Ports: []v1.ServicePort{
{Name: "tcp-example", TargetPort: intstr.FromInt32(80), Protocol: v1.ProtocolTCP},
{Name: "udp-example", TargetPort: intstr.FromInt32(161), Protocol: v1.ProtocolUDP},
{Name: "sctp-example", TargetPort: intstr.FromInt32(3456), Protocol: v1.ProtocolSCTP},
},
Selector: map[string]string{"foo": "bar"},
IPFamilies: []v1.IPFamily{v1.IPv4Protocol},
},
},
externalWorkloads: []*ewv1beta1.ExternalWorkload{
{
ObjectMeta: metav1.ObjectMeta{
Namespace: namespace,
Name: "ew0",
Labels: map[string]string{"foo": "bar"},
DeletionTimestamp: nil,
},
Spec: ewv1beta1.ExternalWorkloadSpec{
WorkloadIPs: []ewv1beta1.WorkloadIP{
{
Ip: "10.0.0.1",
},
},
Ports: []ewv1beta1.PortSpec{
{Name: "tcp-example", Port: 80, Protocol: v1.ProtocolTCP},
{Name: "udp-example", Port: 161, Protocol: v1.ProtocolUDP},
{Name: "sctp-example", Port: 3456, Protocol: v1.ProtocolSCTP},
},
},
Status: ewv1beta1.ExternalWorkloadStatus{
Conditions: []ewv1beta1.WorkloadCondition{
{
Type: ewv1beta1.WorkloadReady,
Status: ewv1beta1.ConditionTrue,
},
},
},
},
{
ObjectMeta: metav1.ObjectMeta{
Namespace: namespace,
Name: "ew1",
Labels: map[string]string{"foo": "bar"},
DeletionTimestamp: nil,
},
Spec: ewv1beta1.ExternalWorkloadSpec{
WorkloadIPs: []ewv1beta1.WorkloadIP{
{
Ip: "10.0.0.2",
},
{
Ip: "fd08::5678:0000:0000:9abc:def0",
},
},
Ports: []ewv1beta1.PortSpec{
{Name: "tcp-example", Port: 80, Protocol: v1.ProtocolTCP},
{Name: "udp-example", Port: 161, Protocol: v1.ProtocolUDP},
{Name: "sctp-example", Port: 3456, Protocol: v1.ProtocolSCTP},
},
},
Status: ewv1beta1.ExternalWorkloadStatus{
Conditions: []ewv1beta1.WorkloadCondition{
{
Type: ewv1beta1.WorkloadReady,
Status: ewv1beta1.ConditionFalse,
},
},
},
},
},
expectedEndpointPorts: []discoveryv1.EndpointPort{
{
Name: ptr.To("udp-example"),
Protocol: protoPtr(v1.ProtocolUDP),
Port: ptr.To(int32(161)),
},
{
Name: ptr.To("sctp-example"),
Protocol: protoPtr(v1.ProtocolSCTP),
Port: ptr.To(int32(3456)),
},
{
Name: ptr.To("tcp-example"),
Protocol: protoPtr(v1.ProtocolTCP),
Port: ptr.To(int32(80)),
},
},
expectedEndpoints: []discoveryv1.Endpoint{
{
Conditions: discoveryv1.EndpointConditions{
Ready: ptr.To(true),
Serving: ptr.To(true),
Terminating: ptr.To(false),
},
Addresses: []string{"10.0.0.1"},
TargetRef: &v1.ObjectReference{Kind: "ExternalWorkload", Namespace: namespace, Name: "ew0"},
},
{
Conditions: discoveryv1.EndpointConditions{
Ready: ptr.To(false),
Serving: ptr.To(false),
Terminating: ptr.To(false),
},
Addresses: []string{"10.0.0.2"},
TargetRef: &v1.ObjectReference{Kind: "ExternalWorkload", Namespace: namespace, Name: "ew1"},
},
},
},
{
name: "Two Ready workloads with the same IPs",
service: &v1.Service{
ObjectMeta: metav1.ObjectMeta{
Name: "foobar",
Namespace: namespace,
CreationTimestamp: creationTimestamp,
},
Spec: v1.ServiceSpec{
Ports: []v1.ServicePort{
{Name: "tcp-example", TargetPort: intstr.FromInt32(80), Protocol: v1.ProtocolTCP},
{Name: "udp-example", TargetPort: intstr.FromInt32(161), Protocol: v1.ProtocolUDP},
{Name: "sctp-example", TargetPort: intstr.FromInt32(3456), Protocol: v1.ProtocolSCTP},
},
Selector: map[string]string{"foo": "bar"},
IPFamilies: []v1.IPFamily{v1.IPv4Protocol},
},
},
externalWorkloads: []*ewv1beta1.ExternalWorkload{
{
ObjectMeta: metav1.ObjectMeta{
Namespace: namespace,
Name: "ew0",
Labels: map[string]string{"foo": "bar"},
DeletionTimestamp: nil,
},
Spec: ewv1beta1.ExternalWorkloadSpec{
WorkloadIPs: []ewv1beta1.WorkloadIP{
{
Ip: "10.0.0.1",
},
},
Ports: []ewv1beta1.PortSpec{
{Name: "tcp-example", Port: 80, Protocol: v1.ProtocolTCP},
{Name: "udp-example", Port: 161, Protocol: v1.ProtocolUDP},
{Name: "sctp-example", Port: 3456, Protocol: v1.ProtocolSCTP},
},
},
Status: ewv1beta1.ExternalWorkloadStatus{
Conditions: []ewv1beta1.WorkloadCondition{
{
Type: ewv1beta1.WorkloadReady,
Status: ewv1beta1.ConditionTrue,
},
},
},
},
{
ObjectMeta: metav1.ObjectMeta{
Namespace: namespace,
Name: "ew1",
Labels: map[string]string{"foo": "bar"},
DeletionTimestamp: nil,
},
Spec: ewv1beta1.ExternalWorkloadSpec{
WorkloadIPs: []ewv1beta1.WorkloadIP{
{
Ip: "10.0.0.1",
},
},
Ports: []ewv1beta1.PortSpec{
{Name: "tcp-example", Port: 80, Protocol: v1.ProtocolTCP},
{Name: "udp-example", Port: 161, Protocol: v1.ProtocolUDP},
{Name: "sctp-example", Port: 3456, Protocol: v1.ProtocolSCTP},
},
},
Status: ewv1beta1.ExternalWorkloadStatus{
Conditions: []ewv1beta1.WorkloadCondition{
{
Type: ewv1beta1.WorkloadReady,
Status: ewv1beta1.ConditionTrue,
},
},
},
},
},
expectedEndpointPorts: []discoveryv1.EndpointPort{
{
Name: ptr.To("udp-example"),
Protocol: protoPtr(v1.ProtocolUDP),
Port: ptr.To(int32(161)),
},
{
Name: ptr.To("sctp-example"),
Protocol: protoPtr(v1.ProtocolSCTP),
Port: ptr.To(int32(3456)),
},
{
Name: ptr.To("tcp-example"),
Protocol: protoPtr(v1.ProtocolTCP),
Port: ptr.To(int32(80)),
},
},
expectedEndpoints: []discoveryv1.Endpoint{
{
Conditions: discoveryv1.EndpointConditions{
Ready: ptr.To(true),
Serving: ptr.To(true),
Terminating: ptr.To(false),
},
Addresses: []string{"10.0.0.1"},
TargetRef: &v1.ObjectReference{Kind: "ExternalWorkload", Namespace: namespace, Name: "ew0"},
},
{
Conditions: discoveryv1.EndpointConditions{
Ready: ptr.To(true),
Serving: ptr.To(true),
Terminating: ptr.To(false),
},
Addresses: []string{"10.0.0.1"},
TargetRef: &v1.ObjectReference{Kind: "ExternalWorkload", Namespace: namespace, Name: "ew1"},
},
},
},
}
for _, testcase := range testcases {
t.Run(testcase.name, func(t *testing.T) {
client, actions, esController := newController(t)
for _, ew := range testcase.externalWorkloads {
esController.externalWorkloadsStore.Add(ew)
}
esController.serviceStore.Add(testcase.service)
_, err := esController.k8sAPI.Client.CoreV1().Services(testcase.service.Namespace).Create(context.TODO(), testcase.service, metav1.CreateOptions{})
if err != nil {
t.Errorf("Expected no error creating service, got: %s", err)
}
err = esController.syncService(fmt.Sprintf("%s/%s", testcase.service.Namespace, testcase.service.Name))
if err != nil {
t.Errorf("Expected no error, got: %s", err)
}
// last action should be to create endpoint slice
expectActions(t, actions(), 1, "create", "endpointslices")
sliceList, err := client.Client.DiscoveryV1().EndpointSlices(testcase.service.Namespace).List(context.TODO(), metav1.ListOptions{})
if err != nil {
t.Errorf("Expected no error fetching endpoint slices, got: %s", err)
}
if len(sliceList.Items) != 1 {
t.Errorf("Expected 1 endpoints slices")
}
// ensure all attributes of endpoint slice match expected state
slice := sliceList.Items[0]
// check expected ports
if !reflect.DeepEqual(testcase.expectedEndpointPorts, slice.Ports) {
t.Error("actual ports do not match expected ones")
}
// sort actual endpoints in terms of targetRef name, in ascending
// order. This will ensure reflection package doesn't give a
// spurious error.
sort.Slice(slice.Endpoints, func(i, j int) bool {
return slice.Endpoints[i].TargetRef.Name < slice.Endpoints[j].TargetRef.Name
})
if !reflect.DeepEqual(testcase.expectedEndpoints, slice.Endpoints) {
t.Error("actual endpoints do not match expected ones")
}
})
}
}
// Test diffing logic that determines if two workloads with the same name and
// namespace have changed enough to warrant reconciliation
// TODO: Add tests for labels change
func TestEwEndpointsChanged(t *testing.T) {
for _, tt := range []struct {
name string
old *ewv1beta1.ExternalWorkload
updated *ewv1beta1.ExternalWorkload
specChanged bool
}{
{
name: "no change",
old: makeExternalWorkload(
"1",
"wlkd1",
nil,
map[int32]string{1: "port-1"},
[]string{"192.0.2.0"},
),
updated: makeExternalWorkload(
"1",
"wlkd1",
nil,
map[int32]string{1: "port-1"},
[]string{"192.0.2.0"},
),
specChanged: false,
},
{
name: "updated workload adds an IP address",
old: makeExternalWorkload(
"1",
"wlkd1",
nil,
map[int32]string{1: "port-1"},
[]string{"192.0.2.0"},
),
updated: makeExternalWorkload(
"2",
"wlkd1",
nil,
map[int32]string{1: "port-1"},
[]string{"192.0.2.0", "192.0.3.0"},
),
specChanged: true,
},
{
name: "updated workload removes an IP address",
old: makeExternalWorkload(
"1",
"wlkd1",
nil,
map[int32]string{1: "port-1"},
[]string{"192.0.2.0", "192.0.3.0"},
),
updated: makeExternalWorkload(
"2",
"wlkd1",
nil,
map[int32]string{1: "port-1"},
[]string{"192.0.2.0"},
),
specChanged: true,
},
{
name: "updated workload changes an IP address",
old: makeExternalWorkload(
"1",
"wlkd1",
nil,
map[int32]string{1: "port-1"},
[]string{"192.0.2.0"},
),
updated: makeExternalWorkload(
"2",
"wlkd1",
nil,
map[int32]string{1: "port-1"},
[]string{"192.0.3.0"},
),
specChanged: true,
},
{
name: "updated workload adds new port",
old: makeExternalWorkload(
"1",
"wlkd1",
nil,
map[int32]string{1: "port-1"},
[]string{"192.0.2.0"},
),
updated: makeExternalWorkload(
"2",
"wlkd1",
nil,
map[int32]string{1: "port-1", 2: "port-2"},
[]string{"192.0.2.0"},
),
specChanged: true,
},
{
name: "updated workload removes port",
old: makeExternalWorkload(
"1",
"wlkd1",
nil,
map[int32]string{1: "port-1", 2: "port-2"},
[]string{"192.0.2.0"},
),
updated: makeExternalWorkload(
"2",
"wlkd1",
nil,
map[int32]string{1: "port-1"},
[]string{"192.0.2.0"},
),
specChanged: true,
},
{
name: "updated workload changes port number",
old: makeExternalWorkload(
"1",
"wlkd1",
nil,
map[int32]string{1: "port-1"},
[]string{"192.0.2.0"},
),
updated: makeExternalWorkload(
"2",
"wlkd1",
nil,
map[int32]string{2: "port-1"},
[]string{"192.0.2.0"},
),
specChanged: true,
},
{
name: "updated workload changes port name",
old: makeExternalWorkload(
"1",
"wlkd1",
nil,
map[int32]string{1: "port-1"},
[]string{"192.0.2.0"},
),
updated: makeExternalWorkload(
"2",
"wlkd1",
nil,
map[int32]string{1: "port-foo"},
[]string{"192.0.2.0"},
),
specChanged: true,
},
{
name: "updated workload removes port name",
old: makeExternalWorkload(
"1",
"wlkd1",
nil,
map[int32]string{1: "port-1"},
[]string{"192.0.2.0"},
),
updated: makeExternalWorkload(
"2",
"wlkd1",
nil,
map[int32]string{1: ""},
[]string{"192.0.2.0"},
),
specChanged: true,
},
} {
tt := tt // Pin
t.Run(tt.name, func(t *testing.T) {
specChanged, _ := ewEndpointsChanged(tt.old, tt.updated)
if tt.specChanged != specChanged {
t.Errorf("expected specChanged '%v', got '%v'", tt.specChanged, specChanged)
}
})
}
}
// Test diffing logic that determines if two workloads with the same name and
// namespace have changed enough to warrant reconciliation
func TestWorkloadServicesToUpdate(t *testing.T) {
for _, tt := range []struct {
name string
old *ewv1beta1.ExternalWorkload
updated *ewv1beta1.ExternalWorkload
k8sConfigs []string
expectServices map[string]struct{}
}{
{
name: "no change",
old: makeExternalWorkload(
"1",
"wlkd1",
map[string]string{"app": "test"},
map[int32]string{1: "port-1"},
[]string{"192.0.2.0"},
),
updated: makeExternalWorkload(
"1",
"wlkd1",
map[string]string{"app": "test"},
map[int32]string{1: "port-1"},
[]string{"192.0.2.0"},
),
k8sConfigs: []string{`
apiVersion: v1
kind: Service
metadata:
name: svc-1
namespace: ns
spec:
selector:
app: test`,
},
expectServices: map[string]struct{}{},
},
{
name: "labels and spec have changed",
old: makeExternalWorkload(
"1",
"wlkd1",
map[string]string{"app": "test-1"},
map[int32]string{1: "port-1"},
[]string{"192.0.2.0"},
),
updated: makeExternalWorkload(
"2",
"wlkd1",
map[string]string{"app": "test-2"},
map[int32]string{2: "port-1"},
[]string{"192.0.2.0"},
),
k8sConfigs: []string{`
apiVersion: v1
kind: Service
metadata:
name: svc-1
namespace: ns
spec:
selector:
app: test-1`, `
apiVersion: v1
kind: Service
metadata:
name: svc-2
namespace: ns
spec:
selector:
app: test-2`,
},
expectServices: map[string]struct{}{"ns/svc-1": {}, "ns/svc-2": {}},
},
{
name: "spec has changed",
old: makeExternalWorkload(
"1",
"wlkd1",
map[string]string{"app": "test-1"},
map[int32]string{1: "port-1"},
[]string{"192.0.2.0"},
),
updated: makeExternalWorkload(
"2",
"wlkd1",
map[string]string{"app": "test-1"},
map[int32]string{2: "port-1"},
[]string{"192.0.2.0"},
),
k8sConfigs: []string{`
apiVersion: v1
kind: Service
metadata:
name: svc-1
namespace: ns
spec:
selector:
app: test-1`,
},
expectServices: map[string]struct{}{"ns/svc-1": {}},
},
{
name: "labels have changed",
old: makeExternalWorkload(
"1",
"wlkd1",
map[string]string{"app": "test-1", "env": "staging"},
map[int32]string{1: "port-1"},
[]string{"192.0.2.0"},
),
updated: makeExternalWorkload(
"2",
"wlkd1",
map[string]string{"app": "test-1", "env": "prod"},
map[int32]string{1: "port-1"},
[]string{"192.0.2.0"},
),
k8sConfigs: []string{`
apiVersion: v1
kind: Service
metadata:
name: internal
namespace: ns
spec:
selector:
app: test-1`, `
apiVersion: v1
kind: Service
metadata:
name: staging
namespace: ns
spec:
selector:
env: staging`, `
apiVersion: v1
kind: Service
metadata:
name: prod
namespace: ns
spec:
selector:
env: prod`,
},
expectServices: map[string]struct{}{"ns/staging": {}, "ns/prod": {}},
}} {
tt := tt // Pin
t.Run(tt.name, func(t *testing.T) {
k8sAPI, err := k8s.NewFakeAPI(tt.k8sConfigs...)
if err != nil {
t.Fatalf("unexpected error %v", err)
}
ec, err := NewEndpointsController(k8sAPI, "my-hostname", "controlplane-ns", make(chan struct{}), false)
if err != nil {
t.Fatalf("unexpected error %v", err)
}
ec.Start()
k8sAPI.Sync(nil)
services := ec.getServicesToUpdateOnExternalWorkloadChange(tt.old, tt.updated)
if len(services) != len(tt.expectServices) {
t.Fatalf("expected %d services to update, got %d services instead", len(tt.expectServices), len(services))
}
for svc := range services {
if _, ok := tt.expectServices[svc]; !ok {
t.Errorf("unexpected service key %s found in list of results", svc)
}
}
})
}
}
// Assert that de-registering handlers won't result in cache staleness issues
//
// The test will simulate a scenario where a lease is acquired, an endpointslice
// created, and the lease is lost. Without wiping out state, this test will
// fail, since any changes made to the resources will not be observed while the
// lease is not held; these changes will result in stale cache entries (since
// the state diverged).
func TestLeaderElectionSyncsState(t *testing.T) {
client, actions, esController := newController(t)
ns := "test-ns"
service := createService(t, esController, ns, "test-svc")
ew1 := newExternalWorkload(1, ns, false, true)
esController.serviceStore.Add(service)
esController.externalWorkloadsStore.Add(ew1)
// Simulate a lease being acquired,
err := esController.addHandlers()
if err != nil {
t.Fatalf("unexpected error when registering client-go callbacks: %v", err)
}
err = esController.syncService(fmt.Sprintf("%s/%s", ns, service.Name))
if err != nil {
t.Fatalf("unexpected error when processing service %s/%s: %v", ns, service.Name, err)
}
expectActions(t, actions(), 1, "create", "endpointslices")
slices, err := client.Client.DiscoveryV1().EndpointSlices(ns).List(context.TODO(), metav1.ListOptions{})
if err != nil {
t.Errorf("expected no error fetching endpoint slices, got: %s", err)
}
if len(slices.Items) != 1 {
t.Errorf("expected 1 endpoint slices, got: %d", len(slices.Items))
}
sliceName := slices.Items[0].Name
// Simulate a lease being lost; we delete the previously created
// endpointslice out-of-band.
err = esController.removeHandlers()
if err != nil {
t.Fatalf("unexpected error when de-registering client-go callbacks: %v", err)
}
err = client.Client.DiscoveryV1().EndpointSlices(ns).Delete(context.TODO(), sliceName, metav1.DeleteOptions{})
if err != nil {
t.Fatalf("unexpected error when deleting endpointslice %s/%s: %v", ns, sliceName, err)
}
slices, err = client.Client.DiscoveryV1().EndpointSlices(ns).List(context.TODO(), metav1.ListOptions{})
if err != nil {
t.Errorf("expected no error fetching endpoint slices, got: %s", err)
}
if len(slices.Items) != 0 {
t.Errorf("expected 0 endpoint slices, got: %d", len(slices.Items))
}
// The lease is re-acquired. We should start with a clean slate to avoid
// cache staleness errors.
esController.addHandlers()
err = esController.syncService(fmt.Sprintf("%s/%s", ns, service.Name))
if err != nil {
t.Fatalf("unexpected error when processing service %s/%s: %v", ns, service.Name, err)
}
expectActions(t, actions(), 1, "create", "endpointslices")
slices, err = client.Client.DiscoveryV1().EndpointSlices(ns).List(context.TODO(), metav1.ListOptions{})
if err != nil {
t.Errorf("expected no error fetching endpoint slices, got: %s", err)
}
if len(slices.Items) != 1 {
t.Errorf("expected 1 endpoint slices, got: %d", len(slices.Items))
}
if slices.Items[0].Name == sliceName {
t.Fatalf("expected newly created slice's name to be different than the initial slice, got: %s", sliceName)
}
}
// protoPtr takes a Protocol and returns a pointer to it.
func protoPtr(proto v1.Protocol) *v1.Protocol {
return &proto
}
func newStatusCondition(ready bool) ewv1beta1.WorkloadCondition {
var status ewv1beta1.WorkloadConditionStatus
if ready {
status = ewv1beta1.ConditionTrue
} else {
status = ewv1beta1.ConditionFalse
}
return ewv1beta1.WorkloadCondition{
Type: ewv1beta1.WorkloadReady,
Status: status,
LastProbeTime: metav1.Time{},
LastTransitionTime: metav1.NewTime(time.Now()),
Reason: "test",
Message: "test",
}
}
//nolint:all
func expectActions(t *testing.T, actions []k8stesting.Action, num int, verb, resource string) {
t.Helper()
// if actions are less the below logic will panic
if num > len(actions) {
t.Fatalf("len of actions %v is unexpected. Expected to be at least %v", len(actions), num+1)
}
for i := 0; i < num; i++ {
relativePos := len(actions) - i - 1
if actions[relativePos].GetVerb() != verb {
t.Errorf("Expected action -%d verb to be %s, was: %s", i, verb, actions[relativePos].GetVerb())
}
if resource != actions[relativePos].GetResource().Resource {
t.Errorf("Expected action -%d resource to be %s, was: %s", i, resource, actions[relativePos].GetResource().Resource)
}
}
}
func createService(t *testing.T, esController *endpointSliceController, namespace, serviceName string) *v1.Service {
t.Helper()
service := &v1.Service{
ObjectMeta: metav1.ObjectMeta{
Name: serviceName,
Namespace: namespace,
CreationTimestamp: metav1.NewTime(time.Now()),
UID: types.UID(namespace + "-" + serviceName),
},
Spec: v1.ServiceSpec{
Ports: []v1.ServicePort{{TargetPort: intstr.FromInt32(80)}},
Selector: map[string]string{"foo": "bar"},
IPFamilies: []v1.IPFamily{v1.IPv4Protocol},
},
}
esController.serviceStore.Add(service)
_, err := esController.k8sAPI.Client.CoreV1().Services(namespace).Create(context.TODO(), service, metav1.CreateOptions{})
if err != nil {
t.Error("Expected no error creating service")
}
return service
}
func standardSyncService(t *testing.T, esController *endpointSliceController, namespace, serviceName string) {
t.Helper()
createService(t, esController, namespace, serviceName)
err := esController.syncService(fmt.Sprintf("%s/%s", namespace, serviceName))
if err != nil {
t.Error("Expected no error syncing service")
}
}