etcd-operator/internal/controller/utils_test.go

599 lines
17 KiB
Go

package controller
import (
"errors"
"fmt"
"testing"
"github.com/go-logr/logr"
"github.com/stretchr/testify/assert"
appsv1 "k8s.io/api/apps/v1"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/client/fake"
"sigs.k8s.io/controller-runtime/pkg/log"
ecv1alpha1 "go.etcd.io/etcd-operator/api/v1alpha1"
"go.etcd.io/etcd-operator/internal/etcdutils"
"go.etcd.io/etcd/api/v3/etcdserverpb"
clientv3 "go.etcd.io/etcd/client/v3"
)
func TestPrepareOwnerReference(t *testing.T) {
scheme := runtime.NewScheme()
ec := &ecv1alpha1.EtcdCluster{}
ec.SetName("test-etcd")
ec.SetNamespace("default")
ec.SetUID("1234")
scheme.AddKnownTypes(schema.GroupVersion{Group: "etcd.database.coreos.com", Version: "v1alpha1"}, ec)
owners, err := prepareOwnerReference(ec, scheme)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if len(owners) != 1 {
t.Fatalf("expected 1 owner reference, got %d", len(owners))
}
if owners[0].Name != "test-etcd" || owners[0].Controller == nil || !*owners[0].Controller {
t.Fatalf("owner reference properties not set correctly")
}
}
func pointerToInt32(value int32) *int32 {
return &value
}
func TestReconcileStatefulSet(t *testing.T) {
scheme := runtime.NewScheme()
_ = ecv1alpha1.AddToScheme(scheme)
fakeClient := fake.NewClientBuilder().Build()
logger := log.FromContext(t.Context())
ec := &ecv1alpha1.EtcdCluster{
ObjectMeta: metav1.ObjectMeta{
Name: "test-etcd",
Namespace: "default",
},
Spec: ecv1alpha1.EtcdClusterSpec{
Size: 3,
Version: "3.5.17",
},
}
_, _ = reconcileStatefulSet(t.Context(), logger, ec, fakeClient, 3, scheme)
sts := &appsv1.StatefulSet{}
err := fakeClient.Get(t.Context(), client.ObjectKey{Name: "test-etcd", Namespace: "default"}, sts)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if *sts.Spec.Replicas != 3 {
t.Fatalf("expected 3 replicas, got %d", *sts.Spec.Replicas)
}
}
func TestWaitForStatefulSetReady(t *testing.T) {
// Create a scheme and register the necessary types
scheme := runtime.NewScheme()
_ = corev1.AddToScheme(scheme)
_ = ecv1alpha1.AddToScheme(scheme)
_ = appsv1.AddToScheme(scheme)
tests := []struct {
name string
statefulSet *appsv1.StatefulSet
expectedResult bool
expectedError error
}{
{
name: "StatefulSet is ready",
statefulSet: &appsv1.StatefulSet{
ObjectMeta: metav1.ObjectMeta{
Name: "test-sts",
Namespace: "default",
},
Spec: appsv1.StatefulSetSpec{
Replicas: pointerToInt32(3),
},
Status: appsv1.StatefulSetStatus{
ReadyReplicas: 3,
},
},
expectedResult: true,
expectedError: nil,
},
{
name: "StatefulSet is not ready",
statefulSet: &appsv1.StatefulSet{
ObjectMeta: metav1.ObjectMeta{
Name: "test-sts",
Namespace: "default",
},
Spec: appsv1.StatefulSetSpec{
Replicas: pointerToInt32(3),
},
Status: appsv1.StatefulSetStatus{
ReadyReplicas: 2,
},
},
expectedResult: false,
expectedError: errors.New("StatefulSet default/test-sts did not become ready: timed out waiting for the condition"),
},
{
name: "StatefulSet does not exist",
statefulSet: nil,
expectedResult: false,
expectedError: errors.New("statefulsets.apps \"test-sts\" not found"),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var clientBuilder *fake.ClientBuilder
if tt.statefulSet != nil {
clientBuilder = fake.NewClientBuilder().WithScheme(scheme).WithObjects(tt.statefulSet)
} else {
clientBuilder = fake.NewClientBuilder().WithScheme(scheme)
}
fakeClient := clientBuilder.Build()
ctx := t.Context()
logger := log.FromContext(ctx)
err := waitForStatefulSetReady(ctx, logger, fakeClient, "test-sts", "default")
if tt.expectedError != nil {
assert.Error(t, err)
assert.Contains(t, err.Error(), tt.expectedError.Error())
} else {
assert.NoError(t, err)
}
})
}
}
func TestCreateHeadlessServiceIfNotExist(t *testing.T) {
ctx := t.Context()
logger := log.FromContext(ctx)
// Create a scheme and register the necessary types
scheme := runtime.NewScheme()
_ = corev1.AddToScheme(scheme)
_ = ecv1alpha1.AddToScheme(scheme)
// Create a fake client
fakeClient := fake.NewClientBuilder().WithScheme(scheme).Build()
// Create an EtcdCluster instance
ec := &ecv1alpha1.EtcdCluster{
ObjectMeta: metav1.ObjectMeta{
Name: "test-etcd",
Namespace: "default",
},
}
t.Run("creates headless service if it does not exist", func(t *testing.T) {
err := createHeadlessServiceIfNotExist(ctx, logger, fakeClient, ec, scheme)
assert.NoError(t, err)
// Verify that the service was created
service := &corev1.Service{}
err = fakeClient.Get(ctx, client.ObjectKey{Name: "test-etcd", Namespace: "default"}, service)
assert.NoError(t, err)
assert.Equal(t, "None", service.Spec.ClusterIP)
assert.Equal(t, map[string]string{
"app": "test-etcd",
"controller": "test-etcd",
}, service.Spec.Selector)
})
t.Run("does not create service if it already exists", func(t *testing.T) {
// Service was already created in previous test. Call the function again to ensure no error
err := createHeadlessServiceIfNotExist(ctx, logger, fakeClient, ec, scheme)
assert.NoError(t, err)
})
}
func TestClientEndpointForOrdinalIndex(t *testing.T) {
sts := &appsv1.StatefulSet{
ObjectMeta: metav1.ObjectMeta{
Name: "test-sts",
Namespace: "default",
},
}
tests := []struct {
index int
expectedResult string
}{
{index: 0, expectedResult: "http://test-sts-0.test-sts.default.svc.cluster.local:2379"},
{index: 1, expectedResult: "http://test-sts-1.test-sts.default.svc.cluster.local:2379"},
{index: 2, expectedResult: "http://test-sts-2.test-sts.default.svc.cluster.local:2379"},
}
for _, tt := range tests {
t.Run(fmt.Sprintf("index %d", tt.index), func(t *testing.T) {
result := clientEndpointForOrdinalIndex(sts, tt.index)
assert.Equal(t, tt.expectedResult, result)
})
}
}
func TestIsLearnerReady(t *testing.T) {
tests := []struct {
name string
leaderStatus *clientv3.StatusResponse
learnerStatus *clientv3.StatusResponse
expectedResult bool
}{
{
name: "Learner is ready",
leaderStatus: &clientv3.StatusResponse{
Header: &etcdserverpb.ResponseHeader{Revision: 100},
},
learnerStatus: &clientv3.StatusResponse{
Header: &etcdserverpb.ResponseHeader{Revision: 95},
},
expectedResult: true,
},
{
name: "Learner is not ready",
leaderStatus: &clientv3.StatusResponse{
Header: &etcdserverpb.ResponseHeader{Revision: 100},
},
learnerStatus: &clientv3.StatusResponse{
Header: &etcdserverpb.ResponseHeader{Revision: 80},
},
expectedResult: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := etcdutils.IsLearnerReady(tt.leaderStatus, tt.learnerStatus)
assert.Equal(t, tt.expectedResult, result)
})
}
}
func TestCheckStatefulSetControlledByEtcdOperator(t *testing.T) {
tests := []struct {
name string
ec *ecv1alpha1.EtcdCluster
sts *appsv1.StatefulSet
expectedError error
}{
{
name: "StatefulSet controlled by EtcdCluster",
ec: &ecv1alpha1.EtcdCluster{
ObjectMeta: metav1.ObjectMeta{
Name: "etcd-cluster",
Namespace: "default",
UID: "1234",
},
},
sts: &appsv1.StatefulSet{
ObjectMeta: metav1.ObjectMeta{
Name: "etcd-sts",
Namespace: "default",
OwnerReferences: []metav1.OwnerReference{
{
APIVersion: "ecv1alpha1/v1alpha1",
Kind: "EtcdCluster",
Name: "etcd-cluster",
UID: "1234",
Controller: pointerToBool(true),
},
},
},
},
expectedError: nil,
},
{
name: "StatefulSet not controlled by EtcdCluster",
ec: &ecv1alpha1.EtcdCluster{
ObjectMeta: metav1.ObjectMeta{
Name: "etcd-cluster",
Namespace: "default",
UID: "1234",
},
},
sts: &appsv1.StatefulSet{
ObjectMeta: metav1.ObjectMeta{
Name: "etcd-sts",
Namespace: "default",
OwnerReferences: []metav1.OwnerReference{
{
APIVersion: "ecv1alpha1/v1alpha1",
Kind: "EtcdCluster",
Name: "other-etcd-cluster",
UID: "5678",
Controller: pointerToBool(true),
},
},
},
},
expectedError: fmt.Errorf("StatefulSet default/etcd-sts is not controlled by EtcdCluster default/etcd-cluster"),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := checkStatefulSetControlledByEtcdOperator(tt.ec, tt.sts)
if (err != nil) != (tt.expectedError != nil) {
t.Errorf("expected error: %v, got: %v", tt.expectedError, err)
return
}
if err != nil && err.Error() != tt.expectedError.Error() {
t.Errorf("unexpected error: got %v, want %v", err, tt.expectedError)
}
})
}
}
func pointerToBool(value bool) *bool {
return &value
}
func TestClientEndpointsFromStatefulsets(t *testing.T) {
tests := []struct {
name string
statefulSet *appsv1.StatefulSet
expectedResult []string
}{
{
name: "StatefulSet with 3 replicas",
statefulSet: &appsv1.StatefulSet{
ObjectMeta: metav1.ObjectMeta{
Name: "test-sts",
Namespace: "default",
},
Spec: appsv1.StatefulSetSpec{
Replicas: pointerToInt32(3),
},
},
expectedResult: []string{
"http://test-sts-0.test-sts.default.svc.cluster.local:2379",
"http://test-sts-1.test-sts.default.svc.cluster.local:2379",
"http://test-sts-2.test-sts.default.svc.cluster.local:2379",
},
},
{
name: "StatefulSet with 1 replica",
statefulSet: &appsv1.StatefulSet{
ObjectMeta: metav1.ObjectMeta{
Name: "test-sts",
Namespace: "default",
},
Spec: appsv1.StatefulSetSpec{
Replicas: pointerToInt32(1),
},
},
expectedResult: []string{
"http://test-sts-0.test-sts.default.svc.cluster.local:2379",
},
},
{
name: "StatefulSet with 0 replicas",
statefulSet: &appsv1.StatefulSet{
ObjectMeta: metav1.ObjectMeta{
Name: "test-sts",
Namespace: "default",
},
Spec: appsv1.StatefulSetSpec{
Replicas: pointerToInt32(0),
},
},
expectedResult: []string(nil),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := clientEndpointsFromStatefulsets(tt.statefulSet)
assert.Equal(t, tt.expectedResult, result)
})
}
}
func TestAreAllMembersHealthy(t *testing.T) {
tests := []struct {
name string
statefulSet *appsv1.StatefulSet
healthInfos []etcdutils.EpHealth
expectedResult bool
expectedError error
}{
// TODO: Add test cases for healthy members and non healthy members
{
name: "Error during health check",
statefulSet: &appsv1.StatefulSet{
ObjectMeta: metav1.ObjectMeta{
Name: "test-sts",
Namespace: "default",
},
Spec: appsv1.StatefulSetSpec{
Replicas: pointerToInt32(3),
},
Status: appsv1.StatefulSetStatus{
ReadyReplicas: 3,
},
},
healthInfos: nil,
expectedResult: false,
expectedError: errors.New("context deadline exceeded"),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
logger := logr.Discard() // Use a no-op logger for testing
result, err := areAllMembersHealthy(tt.statefulSet, logger)
assert.Equal(t, tt.expectedResult, result)
if tt.expectedError != nil {
assert.Error(t, err)
assert.Contains(t, err.Error(), tt.expectedError.Error())
} else {
assert.NoError(t, err)
}
})
}
}
func TestApplyEtcdClusterState(t *testing.T) {
ctx := t.Context()
logger := log.FromContext(ctx)
// Create a scheme and register the necessary types
scheme := runtime.NewScheme()
_ = corev1.AddToScheme(scheme)
_ = ecv1alpha1.AddToScheme(scheme)
// Create a fake client
fakeClient := fake.NewClientBuilder().WithScheme(scheme).Build()
// Create an EtcdCluster instance
ec := &ecv1alpha1.EtcdCluster{
ObjectMeta: metav1.ObjectMeta{
Name: "test-etcd",
Namespace: "default",
},
}
t.Run("creates configmap if it does not exist", func(t *testing.T) {
err := applyEtcdClusterState(ctx, ec, 3, fakeClient, scheme, logger)
assert.NoError(t, err)
// Verify that the configmap was created
configMap := &corev1.ConfigMap{}
err = fakeClient.Get(ctx, client.ObjectKey{Name: configMapNameForEtcdCluster(ec), Namespace: "default"}, configMap)
assert.NoError(t, err)
assert.Equal(t, "existing", configMap.Data["ETCD_INITIAL_CLUSTER_STATE"])
assert.Contains(t, configMap.Data["ETCD_INITIAL_CLUSTER"], "test-etcd-0=http://test-etcd-0.test-etcd.default.svc.cluster.local:2380")
err = fakeClient.Delete(ctx, configMap) // Delete the configmap to avoid conflicts in future tests
assert.NoError(t, err)
})
t.Run("updates configmap if it already exists", func(t *testing.T) {
// Create the configmap first
configMap := newEtcdClusterState(ec, 3)
err := fakeClient.Create(ctx, configMap)
assert.NoError(t, err)
// Call the function again to ensure it updates the configmap
err = applyEtcdClusterState(ctx, ec, 3, fakeClient, scheme, logger)
assert.NoError(t, err)
// Verify that the configmap was updated
updatedConfigMap := &corev1.ConfigMap{}
err = fakeClient.Get(ctx, client.ObjectKey{Name: configMapNameForEtcdCluster(ec), Namespace: "default"}, updatedConfigMap)
assert.NoError(t, err)
assert.Equal(t, "existing", updatedConfigMap.Data["ETCD_INITIAL_CLUSTER_STATE"])
assert.Contains(t, updatedConfigMap.Data["ETCD_INITIAL_CLUSTER"], "test-etcd-0=http://test-etcd-0.test-etcd.default.svc.cluster.local:2380")
})
}
func TestCreatingArgs(t *testing.T) {
tests := []struct {
testName string
etcdOptions []string
clusterName string
expectedResult []string
}{
{
testName: "No etcdOptions provided",
etcdOptions: nil,
clusterName: "testCluster",
expectedResult: []string{
"--name=$(POD_NAME)",
"--listen-peer-urls=http://0.0.0.0:2380",
"--listen-client-urls=http://0.0.0.0:2379",
"--initial-advertise-peer-urls=http://$(POD_NAME).testCluster.$(POD_NAMESPACE).svc.cluster.local:2380",
"--advertise-client-urls=http://$(POD_NAME).testCluster.$(POD_NAMESPACE).svc.cluster.local:2379",
},
},
{
testName: "Etcd options with = sign",
etcdOptions: []string{
"--max-wals=7",
"--discovery-failbox=proxy",
},
clusterName: "testCluster",
expectedResult: []string{
"--name=$(POD_NAME)",
"--listen-peer-urls=http://0.0.0.0:2380",
"--listen-client-urls=http://0.0.0.0:2379",
"--initial-advertise-peer-urls=http://$(POD_NAME).testCluster.$(POD_NAMESPACE).svc.cluster.local:2380",
"--advertise-client-urls=http://$(POD_NAME).testCluster.$(POD_NAMESPACE).svc.cluster.local:2379",
"--max-wals=7",
"--discovery-failbox=proxy",
},
},
{
testName: "Etcd options with spaces",
etcdOptions: []string{
"--max-wals 7",
"--discovery-failbox proxy",
},
clusterName: "testCluster",
expectedResult: []string{
"--name=$(POD_NAME)",
"--listen-peer-urls=http://0.0.0.0:2380",
"--listen-client-urls=http://0.0.0.0:2379",
"--initial-advertise-peer-urls=http://$(POD_NAME).testCluster.$(POD_NAMESPACE).svc.cluster.local:2380",
"--advertise-client-urls=http://$(POD_NAME).testCluster.$(POD_NAMESPACE).svc.cluster.local:2379",
"--max-wals 7",
"--discovery-failbox proxy",
},
},
{
testName: "Etcd switch options",
etcdOptions: []string{
"--experimental-peer-skip-client-san-verification",
},
clusterName: "testCluster",
expectedResult: []string{
"--name=$(POD_NAME)",
"--listen-peer-urls=http://0.0.0.0:2380",
"--listen-client-urls=http://0.0.0.0:2379",
"--initial-advertise-peer-urls=http://$(POD_NAME).testCluster.$(POD_NAMESPACE).svc.cluster.local:2380",
"--advertise-client-urls=http://$(POD_NAME).testCluster.$(POD_NAMESPACE).svc.cluster.local:2379",
"--experimental-peer-skip-client-san-verification",
},
},
{
testName: "Overwrite default arg",
etcdOptions: []string{
"--listen-peer-urls=http://0.0.0.0:3200",
"--experimental-peer-skip-client-san-verification",
},
clusterName: "testCluster",
expectedResult: []string{
"--name=$(POD_NAME)",
"--listen-client-urls=http://0.0.0.0:2379",
"--initial-advertise-peer-urls=http://$(POD_NAME).testCluster.$(POD_NAMESPACE).svc.cluster.local:2380",
"--advertise-client-urls=http://$(POD_NAME).testCluster.$(POD_NAMESPACE).svc.cluster.local:2379",
"--listen-peer-urls=http://0.0.0.0:3200",
"--experimental-peer-skip-client-san-verification",
},
},
}
for _, tt := range tests {
t.Run(tt.testName, func(t *testing.T) {
result := createArgs(tt.clusterName, tt.etcdOptions)
assert.Equal(t, tt.expectedResult, result)
})
}
}