Files
kubemirror/pkg/controller/source_reconciler_test.go
T

469 lines
12 KiB
Go

package controller
import (
"context"
"fmt"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/api/meta"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apimachinery/pkg/types"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"github.com/lukaszraczylo/kubemirror/pkg/config"
"github.com/lukaszraczylo/kubemirror/pkg/constants"
"github.com/lukaszraczylo/kubemirror/pkg/filter"
)
// MockClient is a mock implementation of client.Client for testing.
type MockClient struct {
mock.Mock
}
func (m *MockClient) Get(ctx context.Context, key types.NamespacedName, obj client.Object, opts ...client.GetOption) error {
args := m.Called(ctx, key, obj)
if args.Error(0) != nil {
return args.Error(0)
}
// Copy the mock object into obj
if mockObj := args.Get(1); mockObj != nil {
switch v := mockObj.(type) {
case *corev1.Secret:
*obj.(*corev1.Secret) = *v
case *corev1.ConfigMap:
*obj.(*corev1.ConfigMap) = *v
case *unstructured.Unstructured:
// Copy the unstructured object
*obj.(*unstructured.Unstructured) = *v
}
}
return nil
}
func (m *MockClient) List(ctx context.Context, list client.ObjectList, opts ...client.ListOption) error {
args := m.Called(ctx, list, opts)
return args.Error(0)
}
func (m *MockClient) Create(ctx context.Context, obj client.Object, opts ...client.CreateOption) error {
args := m.Called(ctx, obj, opts)
return args.Error(0)
}
func (m *MockClient) Delete(ctx context.Context, obj client.Object, opts ...client.DeleteOption) error {
args := m.Called(ctx, obj, opts)
return args.Error(0)
}
func (m *MockClient) Update(ctx context.Context, obj client.Object, opts ...client.UpdateOption) error {
args := m.Called(ctx, obj, opts)
return args.Error(0)
}
func (m *MockClient) Patch(ctx context.Context, obj client.Object, patch client.Patch, opts ...client.PatchOption) error {
args := m.Called(ctx, obj, patch, opts)
return args.Error(0)
}
func (m *MockClient) DeleteAllOf(ctx context.Context, obj client.Object, opts ...client.DeleteAllOfOption) error {
args := m.Called(ctx, obj, opts)
return args.Error(0)
}
func (m *MockClient) Status() client.StatusWriter {
args := m.Called()
return args.Get(0).(client.StatusWriter)
}
func (m *MockClient) Scheme() *runtime.Scheme {
args := m.Called()
return args.Get(0).(*runtime.Scheme)
}
func (m *MockClient) RESTMapper() meta.RESTMapper {
args := m.Called()
return args.Get(0).(meta.RESTMapper)
}
func (m *MockClient) GroupVersionKindFor(obj runtime.Object) (schema.GroupVersionKind, error) {
args := m.Called(obj)
return args.Get(0).(schema.GroupVersionKind), args.Error(1)
}
func (m *MockClient) IsObjectNamespaced(obj runtime.Object) (bool, error) {
args := m.Called(obj)
return args.Bool(0), args.Error(1)
}
func (m *MockClient) SubResource(subResource string) client.SubResourceClient {
args := m.Called(subResource)
return args.Get(0).(client.SubResourceClient)
}
func (m *MockClient) Apply(ctx context.Context, obj runtime.ApplyConfiguration, opts ...client.ApplyOption) error {
args := m.Called(ctx, obj, opts)
return args.Error(0)
}
// MockNamespaceLister is a mock implementation of NamespaceLister for testing.
type MockNamespaceLister struct {
mock.Mock
}
func (m *MockNamespaceLister) ListNamespaces(ctx context.Context) ([]string, error) {
args := m.Called(ctx)
return args.Get(0).([]string), args.Error(1)
}
func (m *MockNamespaceLister) ListAllowMirrorsNamespaces(ctx context.Context) ([]string, error) {
args := m.Called(ctx)
return args.Get(0).([]string), args.Error(1)
}
func TestIsEnabledForMirroring(t *testing.T) {
tests := []struct {
obj metav1.Object
name string
want bool
}{
{
name: "enabled with both label and annotation",
obj: &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Labels: map[string]string{
constants.LabelEnabled: "true",
},
Annotations: map[string]string{
constants.AnnotationSync: "true",
},
},
},
want: true,
},
{
name: "missing label",
obj: &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Annotations: map[string]string{
constants.AnnotationSync: "true",
},
},
},
want: false,
},
{
name: "missing annotation",
obj: &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Labels: map[string]string{
constants.LabelEnabled: "true",
},
},
},
want: false,
},
{
name: "label set to false",
obj: &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Labels: map[string]string{
constants.LabelEnabled: "false",
},
Annotations: map[string]string{
constants.AnnotationSync: "true",
},
},
},
want: false,
},
{
name: "no labels or annotations",
obj: &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{},
},
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := isEnabledForMirroring(tt.obj)
assert.Equal(t, tt.want, got)
})
}
}
func TestSourceReconciler_resolveTargetNamespaces(t *testing.T) {
tests := []struct {
name string
sourceAnnotations map[string]string
allNamespaces []string
allowMirrorsNamespaces []string
sourceNamespace string
wantContains []string
wantNotContains []string
wantError bool
expectListCalls bool
}{
{
name: "no target annotation",
sourceAnnotations: map[string]string{
constants.AnnotationSync: "true",
},
allNamespaces: []string{"app1", "app2"},
sourceNamespace: "default",
wantContains: nil,
expectListCalls: false,
},
{
name: "single target namespace",
sourceAnnotations: map[string]string{
constants.AnnotationTargetNamespaces: "app1",
},
allNamespaces: []string{"app1", "app2", "default"},
sourceNamespace: "default",
wantContains: []string{"app1"},
wantNotContains: []string{"app2", "default"},
expectListCalls: true,
},
{
name: "multiple target namespaces",
sourceAnnotations: map[string]string{
constants.AnnotationTargetNamespaces: "app1,app2",
},
allNamespaces: []string{"app1", "app2", "app3", "default"},
sourceNamespace: "default",
wantContains: []string{"app1", "app2"},
wantNotContains: []string{"app3", "default"},
expectListCalls: true,
},
{
name: "all keyword",
sourceAnnotations: map[string]string{
constants.AnnotationTargetNamespaces: "all",
},
allNamespaces: []string{"app1", "app2", "default"},
sourceNamespace: "default",
wantContains: []string{"app1", "app2"},
wantNotContains: []string{"default"}, // source excluded
expectListCalls: true,
},
{
name: "pattern matching",
sourceAnnotations: map[string]string{
constants.AnnotationTargetNamespaces: "app-*",
},
allNamespaces: []string{"app-frontend", "app-backend", "prod-api", "default"},
sourceNamespace: "default",
wantContains: []string{"app-frontend", "app-backend"},
wantNotContains: []string{"prod-api", "default"},
expectListCalls: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mockLister := new(MockNamespaceLister)
if tt.expectListCalls {
mockLister.On("ListNamespaces", mock.Anything).Return(tt.allNamespaces, nil)
mockLister.On("ListAllowMirrorsNamespaces", mock.Anything).Return(tt.allowMirrorsNamespaces, nil)
}
r := &SourceReconciler{
Config: &config.Config{},
Filter: filter.NewNamespaceFilter([]string{}, []string{}),
NamespaceLister: mockLister,
}
sourceObj := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: "test-secret",
Namespace: tt.sourceNamespace,
Annotations: tt.sourceAnnotations,
},
}
got, err := r.resolveTargetNamespaces(context.Background(), sourceObj)
if tt.wantError {
require.Error(t, err)
return
}
require.NoError(t, err)
if tt.wantContains != nil {
for _, ns := range tt.wantContains {
assert.Contains(t, got, ns)
}
}
if tt.wantNotContains != nil {
for _, ns := range tt.wantNotContains {
assert.NotContains(t, got, ns)
}
}
if tt.expectListCalls {
mockLister.AssertExpectations(t)
}
})
}
}
func TestSourceReconciler_Reconcile_MirrorResource(t *testing.T) {
// Test that mirrors are not reconciled as sources
mockClient := new(MockClient)
mockLister := new(MockNamespaceLister)
r := &SourceReconciler{
Client: mockClient,
Scheme: runtime.NewScheme(),
Config: &config.Config{},
Filter: filter.NewNamespaceFilter([]string{}, []string{}),
NamespaceLister: mockLister,
GVK: schema.GroupVersionKind{
Group: "",
Version: "v1",
Kind: "Secret",
},
}
// Create a mirror resource (has the mirror label) as unstructured
mirrorSecret := &unstructured.Unstructured{
Object: map[string]interface{}{
"apiVersion": "v1",
"kind": "Secret",
"metadata": map[string]interface{}{
"name": "test-secret",
"namespace": "app1",
"labels": map[string]interface{}{
constants.LabelManagedBy: constants.ControllerName,
constants.LabelMirror: "true",
},
},
},
}
mockClient.On("Get", mock.Anything, mock.Anything, mock.AnythingOfType("*unstructured.Unstructured")).
Return(nil, mirrorSecret)
req := ctrl.Request{
NamespacedName: types.NamespacedName{
Namespace: "app1",
Name: "test-secret",
},
}
result, err := r.Reconcile(context.Background(), req)
require.NoError(t, err)
assert.Equal(t, ctrl.Result{}, result)
mockClient.AssertExpectations(t)
}
func TestSourceReconciler_Reconcile_NotFound(t *testing.T) {
// Test that deleted resources are handled gracefully
mockClient := new(MockClient)
mockLister := new(MockNamespaceLister)
r := &SourceReconciler{
Client: mockClient,
Scheme: runtime.NewScheme(),
Config: &config.Config{},
Filter: filter.NewNamespaceFilter([]string{}, []string{}),
NamespaceLister: mockLister,
GVK: schema.GroupVersionKind{
Group: "",
Version: "v1",
Kind: "Secret",
},
}
notFoundErr := errors.NewNotFound(schema.GroupResource{
Group: "",
Resource: "secrets",
}, "test-secret")
mockClient.On("Get", mock.Anything, mock.Anything, mock.AnythingOfType("*unstructured.Unstructured")).
Return(notFoundErr, (*unstructured.Unstructured)(nil))
req := ctrl.Request{
NamespacedName: types.NamespacedName{
Namespace: "default",
Name: "test-secret",
},
}
result, err := r.Reconcile(context.Background(), req)
require.NoError(t, err)
assert.Equal(t, ctrl.Result{}, result)
mockClient.AssertExpectations(t)
}
// Benchmark tests for performance-critical paths
func BenchmarkIsEnabledForMirroring(b *testing.B) {
obj := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Labels: map[string]string{
constants.LabelEnabled: "true",
},
Annotations: map[string]string{
constants.AnnotationSync: "true",
},
},
}
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
_ = isEnabledForMirroring(obj)
}
}
func BenchmarkResolveTargetNamespaces(b *testing.B) {
mockLister := new(MockNamespaceLister)
allNamespaces := make([]string, 100)
for i := 0; i < 100; i++ {
allNamespaces[i] = fmt.Sprintf("namespace-%d", i)
}
mockLister.On("ListNamespaces", mock.Anything).Return(allNamespaces, nil)
mockLister.On("ListAllowMirrorsNamespaces", mock.Anything).Return(allNamespaces[:50], nil)
r := &SourceReconciler{
Config: &config.Config{},
Filter: filter.NewNamespaceFilter([]string{}, []string{}),
NamespaceLister: mockLister,
}
sourceObj := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: "test-secret",
Namespace: "default",
Annotations: map[string]string{
constants.AnnotationTargetNamespaces: "all",
},
},
}
ctx := context.Background()
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
_, _ = r.resolveTargetNamespaces(ctx, sourceObj)
}
}