mirror of
https://github.com/lukaszraczylo/kubemirror.git
synced 2026-06-05 22:43:51 +00:00
803 lines
24 KiB
Go
803 lines
24 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 (m *MockNamespaceLister) ListOptOutNamespaces(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)
|
|
mockLister.On("ListOptOutNamespaces", mock.Anything).Return([]string{}, 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)
|
|
mockLister.On("ListOptOutNamespaces", mock.Anything).Return([]string{}, 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)
|
|
}
|
|
}
|
|
|
|
func TestSourceReconciler_cleanupOrphanedMirrors(t *testing.T) {
|
|
// Setup: Source in default namespace with mirrors in app-1, app-2, app-3
|
|
// Then target-namespaces changes to only app-1, app-2
|
|
// Expect: app-3 mirror is deleted (orphaned)
|
|
|
|
sourceObj := &unstructured.Unstructured{
|
|
Object: map[string]interface{}{
|
|
"apiVersion": "v1",
|
|
"kind": "Secret",
|
|
"metadata": map[string]interface{}{
|
|
"name": "test-secret",
|
|
"namespace": "default",
|
|
"uid": "source-uid-123",
|
|
},
|
|
},
|
|
}
|
|
|
|
// Mock existing mirror in app-3 (will be orphaned)
|
|
orphanedMirror := &unstructured.Unstructured{
|
|
Object: map[string]interface{}{
|
|
"apiVersion": "v1",
|
|
"kind": "Secret",
|
|
"metadata": map[string]interface{}{
|
|
"name": "test-secret",
|
|
"namespace": "app-3",
|
|
"labels": map[string]interface{}{
|
|
constants.LabelManagedBy: constants.ControllerName,
|
|
constants.LabelMirror: "true",
|
|
},
|
|
"annotations": map[string]interface{}{
|
|
constants.AnnotationSourceNamespace: "default",
|
|
constants.AnnotationSourceName: "test-secret",
|
|
constants.AnnotationSourceUID: "source-uid-123",
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
mockClient := new(MockClient)
|
|
mockLister := new(MockNamespaceLister)
|
|
|
|
// Setup: all namespaces in cluster
|
|
allNamespaces := []string{"default", "app-1", "app-2", "app-3", "prod-1"}
|
|
mockLister.On("ListNamespaces", mock.Anything).Return(allNamespaces, nil)
|
|
|
|
// Current target list (after annotation change): only app-1 and app-2
|
|
targetNamespaces := []string{"app-1", "app-2"}
|
|
|
|
// The function will iterate through all namespaces and:
|
|
// - Skip "default" (source namespace)
|
|
// - Skip "app-1" and "app-2" (in target list)
|
|
// - Check "app-3" (not in target list) → will find orphaned mirror
|
|
// - Check "prod-1" (not in target list) → no mirror exists
|
|
|
|
notFoundErr := errors.NewNotFound(schema.GroupResource{Group: "", Resource: "secrets"}, "test-secret")
|
|
|
|
// app-3: orphaned mirror exists
|
|
mockClient.On("Get", mock.Anything, types.NamespacedName{Namespace: "app-3", Name: "test-secret"}, mock.Anything).
|
|
Return(nil, orphanedMirror)
|
|
|
|
// prod-1: no mirror exists
|
|
mockClient.On("Get", mock.Anything, types.NamespacedName{Namespace: "prod-1", Name: "test-secret"}, mock.Anything).
|
|
Return(notFoundErr, nil)
|
|
|
|
// Expect delete call for app-3 mirror
|
|
mockClient.On("Delete", mock.Anything, mock.MatchedBy(func(obj client.Object) bool {
|
|
u, ok := obj.(*unstructured.Unstructured)
|
|
return ok && u.GetNamespace() == "app-3" && u.GetName() == "test-secret"
|
|
}), mock.Anything).Return(nil)
|
|
|
|
r := &SourceReconciler{
|
|
Client: mockClient,
|
|
NamespaceLister: mockLister,
|
|
}
|
|
|
|
ctx := context.Background()
|
|
deletedCount, err := r.cleanupOrphanedMirrors(ctx, sourceObj, targetNamespaces)
|
|
|
|
require.NoError(t, err)
|
|
assert.Equal(t, 1, deletedCount, "should have deleted 1 orphaned mirror")
|
|
|
|
mockClient.AssertExpectations(t)
|
|
mockLister.AssertExpectations(t)
|
|
}
|
|
|
|
func TestSourceReconciler_Reconcile_AnnotationChange_AllToAllLabeled(t *testing.T) {
|
|
// Scenario: annotation changes from "all" → "all-labeled"
|
|
// Before: mirrors in all 5 namespaces
|
|
// After: mirrors only in labeled namespaces (app-1, app-2)
|
|
// Expected: 3 orphaned mirrors deleted (app-3, prod-1, prod-2)
|
|
|
|
source := &unstructured.Unstructured{
|
|
Object: map[string]interface{}{
|
|
"apiVersion": "v1",
|
|
"kind": "Secret",
|
|
"metadata": map[string]interface{}{
|
|
"name": "test-secret",
|
|
"namespace": "default",
|
|
"uid": "source-uid-123",
|
|
"labels": map[string]interface{}{
|
|
constants.LabelEnabled: "true",
|
|
},
|
|
"annotations": map[string]interface{}{
|
|
constants.AnnotationSync: "true",
|
|
constants.AnnotationTargetNamespaces: "all-labeled", // Changed from "all"
|
|
},
|
|
},
|
|
"data": map[string]interface{}{
|
|
"password": "c2VjcmV0",
|
|
},
|
|
},
|
|
}
|
|
|
|
// Setup: 5 total namespaces, only 2 have allow-mirrors label
|
|
allNamespaces := []string{"default", "app-1", "app-2", "app-3", "prod-1", "prod-2"}
|
|
allowMirrorsNamespaces := []string{"app-1", "app-2"}
|
|
|
|
// Mock existing orphaned mirrors in app-3, prod-1, prod-2
|
|
createOrphanedMirror := func(ns string) *unstructured.Unstructured {
|
|
return &unstructured.Unstructured{
|
|
Object: map[string]interface{}{
|
|
"apiVersion": "v1",
|
|
"kind": "Secret",
|
|
"metadata": map[string]interface{}{
|
|
"name": "test-secret",
|
|
"namespace": ns,
|
|
"labels": map[string]interface{}{
|
|
constants.LabelManagedBy: constants.ControllerName,
|
|
constants.LabelMirror: "true",
|
|
},
|
|
"annotations": map[string]interface{}{
|
|
constants.AnnotationSourceNamespace: "default",
|
|
constants.AnnotationSourceName: "test-secret",
|
|
constants.AnnotationSourceUID: "source-uid-123",
|
|
},
|
|
},
|
|
"data": map[string]interface{}{
|
|
"password": "c2VjcmV0",
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
mockClient := new(MockClient)
|
|
mockLister := new(MockNamespaceLister)
|
|
mockFilter := filter.NewNamespaceFilter(nil, nil)
|
|
|
|
mockLister.On("ListNamespaces", mock.Anything).Return(allNamespaces, nil)
|
|
mockLister.On("ListAllowMirrorsNamespaces", mock.Anything).Return(allowMirrorsNamespaces, nil)
|
|
mockLister.On("ListOptOutNamespaces", mock.Anything).Return([]string{}, nil)
|
|
|
|
// Mock Get for source
|
|
mockClient.On("Get", mock.Anything, types.NamespacedName{Namespace: "default", Name: "test-secret"}, mock.Anything).
|
|
Return(nil, source)
|
|
|
|
// Mock reconcileMirror calls for app-1 and app-2 (current targets)
|
|
notFoundErr := errors.NewNotFound(schema.GroupResource{Group: "", Resource: "secrets"}, "test-secret")
|
|
mockClient.On("Get", mock.Anything, types.NamespacedName{Namespace: "app-1", Name: "test-secret"}, mock.Anything).
|
|
Return(notFoundErr, nil).Once()
|
|
mockClient.On("Create", mock.Anything, mock.MatchedBy(func(obj client.Object) bool {
|
|
return obj.GetNamespace() == "app-1"
|
|
}), mock.Anything).Return(nil).Once()
|
|
|
|
mockClient.On("Get", mock.Anything, types.NamespacedName{Namespace: "app-2", Name: "test-secret"}, mock.Anything).
|
|
Return(notFoundErr, nil).Once()
|
|
mockClient.On("Create", mock.Anything, mock.MatchedBy(func(obj client.Object) bool {
|
|
return obj.GetNamespace() == "app-2"
|
|
}), mock.Anything).Return(nil).Once()
|
|
|
|
// Mock cleanup: check orphaned namespaces app-3, prod-1, prod-2
|
|
mockClient.On("Get", mock.Anything, types.NamespacedName{Namespace: "app-3", Name: "test-secret"}, mock.Anything).
|
|
Return(nil, createOrphanedMirror("app-3")).Once()
|
|
mockClient.On("Delete", mock.Anything, mock.MatchedBy(func(obj client.Object) bool {
|
|
return obj.GetNamespace() == "app-3"
|
|
}), mock.Anything).Return(nil).Once()
|
|
|
|
mockClient.On("Get", mock.Anything, types.NamespacedName{Namespace: "prod-1", Name: "test-secret"}, mock.Anything).
|
|
Return(nil, createOrphanedMirror("prod-1")).Once()
|
|
mockClient.On("Delete", mock.Anything, mock.MatchedBy(func(obj client.Object) bool {
|
|
return obj.GetNamespace() == "prod-1"
|
|
}), mock.Anything).Return(nil).Once()
|
|
|
|
mockClient.On("Get", mock.Anything, types.NamespacedName{Namespace: "prod-2", Name: "test-secret"}, mock.Anything).
|
|
Return(nil, createOrphanedMirror("prod-2")).Once()
|
|
mockClient.On("Delete", mock.Anything, mock.MatchedBy(func(obj client.Object) bool {
|
|
return obj.GetNamespace() == "prod-2"
|
|
}), mock.Anything).Return(nil).Once()
|
|
|
|
// Mock Update for status annotation
|
|
mockClient.On("Update", mock.Anything, mock.Anything, mock.Anything).Return(nil)
|
|
|
|
r := &SourceReconciler{
|
|
Client: mockClient,
|
|
Scheme: runtime.NewScheme(),
|
|
Config: &config.Config{},
|
|
Filter: mockFilter,
|
|
NamespaceLister: mockLister,
|
|
GVK: schema.GroupVersionKind{Group: "", Version: "v1", Kind: "Secret"},
|
|
}
|
|
|
|
ctx := context.Background()
|
|
req := ctrl.Request{NamespacedName: types.NamespacedName{Namespace: "default", Name: "test-secret"}}
|
|
|
|
result, err := r.Reconcile(ctx, req)
|
|
|
|
require.NoError(t, err)
|
|
assert.Equal(t, ctrl.Result{}, result)
|
|
|
|
mockClient.AssertExpectations(t)
|
|
mockLister.AssertExpectations(t)
|
|
}
|
|
|
|
func TestSourceReconciler_Reconcile_AnnotationChange_PatternChange(t *testing.T) {
|
|
// Scenario: annotation changes from "app-*" → "prod-*"
|
|
// Before: mirrors in app-1, app-2, app-3
|
|
// After: mirrors in prod-1, prod-2
|
|
// Expected: app-1, app-2, app-3 deleted; prod-1, prod-2 created
|
|
|
|
source := &unstructured.Unstructured{
|
|
Object: map[string]interface{}{
|
|
"apiVersion": "v1",
|
|
"kind": "ConfigMap",
|
|
"metadata": map[string]interface{}{
|
|
"name": "app-config",
|
|
"namespace": "default",
|
|
"uid": "config-uid-456",
|
|
"labels": map[string]interface{}{
|
|
constants.LabelEnabled: "true",
|
|
},
|
|
"annotations": map[string]interface{}{
|
|
constants.AnnotationSync: "true",
|
|
constants.AnnotationTargetNamespaces: "prod-*", // Changed from "app-*"
|
|
},
|
|
},
|
|
"data": map[string]interface{}{
|
|
"key": "value",
|
|
},
|
|
},
|
|
}
|
|
|
|
allNamespaces := []string{"default", "app-1", "app-2", "app-3", "prod-1", "prod-2"}
|
|
|
|
createOrphanedMirror := func(ns string) *unstructured.Unstructured {
|
|
return &unstructured.Unstructured{
|
|
Object: map[string]interface{}{
|
|
"apiVersion": "v1",
|
|
"kind": "ConfigMap",
|
|
"metadata": map[string]interface{}{
|
|
"name": "app-config",
|
|
"namespace": ns,
|
|
"labels": map[string]interface{}{
|
|
constants.LabelManagedBy: constants.ControllerName,
|
|
constants.LabelMirror: "true",
|
|
},
|
|
"annotations": map[string]interface{}{
|
|
constants.AnnotationSourceNamespace: "default",
|
|
constants.AnnotationSourceName: "app-config",
|
|
constants.AnnotationSourceUID: "config-uid-456",
|
|
},
|
|
},
|
|
"data": map[string]interface{}{
|
|
"key": "value",
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
mockClient := new(MockClient)
|
|
mockLister := new(MockNamespaceLister)
|
|
mockFilter := filter.NewNamespaceFilter(nil, nil)
|
|
|
|
mockLister.On("ListNamespaces", mock.Anything).Return(allNamespaces, nil)
|
|
mockLister.On("ListAllowMirrorsNamespaces", mock.Anything).Return([]string{}, nil)
|
|
mockLister.On("ListOptOutNamespaces", mock.Anything).Return([]string{}, nil)
|
|
|
|
// Mock Get for source
|
|
mockClient.On("Get", mock.Anything, types.NamespacedName{Namespace: "default", Name: "app-config"}, mock.Anything).
|
|
Return(nil, source)
|
|
|
|
notFoundErr := errors.NewNotFound(schema.GroupResource{Group: "", Resource: "configmaps"}, "app-config")
|
|
|
|
// Mock reconcileMirror for prod-1 and prod-2 (new targets)
|
|
mockClient.On("Get", mock.Anything, types.NamespacedName{Namespace: "prod-1", Name: "app-config"}, mock.Anything).
|
|
Return(notFoundErr, nil).Once()
|
|
mockClient.On("Create", mock.Anything, mock.MatchedBy(func(obj client.Object) bool {
|
|
return obj.GetNamespace() == "prod-1"
|
|
}), mock.Anything).Return(nil).Once()
|
|
|
|
mockClient.On("Get", mock.Anything, types.NamespacedName{Namespace: "prod-2", Name: "app-config"}, mock.Anything).
|
|
Return(notFoundErr, nil).Once()
|
|
mockClient.On("Create", mock.Anything, mock.MatchedBy(func(obj client.Object) bool {
|
|
return obj.GetNamespace() == "prod-2"
|
|
}), mock.Anything).Return(nil).Once()
|
|
|
|
// Mock cleanup: delete orphaned mirrors in app-1, app-2, app-3
|
|
for _, ns := range []string{"app-1", "app-2", "app-3"} {
|
|
mockClient.On("Get", mock.Anything, types.NamespacedName{Namespace: ns, Name: "app-config"}, mock.Anything).
|
|
Return(nil, createOrphanedMirror(ns)).Once()
|
|
mockClient.On("Delete", mock.Anything, mock.MatchedBy(func(obj client.Object) bool {
|
|
return obj.GetNamespace() == ns
|
|
}), mock.Anything).Return(nil).Once()
|
|
}
|
|
|
|
// Mock Update for status
|
|
mockClient.On("Update", mock.Anything, mock.Anything, mock.Anything).Return(nil)
|
|
|
|
r := &SourceReconciler{
|
|
Client: mockClient,
|
|
Scheme: runtime.NewScheme(),
|
|
Config: &config.Config{},
|
|
Filter: mockFilter,
|
|
NamespaceLister: mockLister,
|
|
GVK: schema.GroupVersionKind{Group: "", Version: "v1", Kind: "ConfigMap"},
|
|
}
|
|
|
|
ctx := context.Background()
|
|
req := ctrl.Request{NamespacedName: types.NamespacedName{Namespace: "default", Name: "app-config"}}
|
|
|
|
result, err := r.Reconcile(ctx, req)
|
|
|
|
require.NoError(t, err)
|
|
assert.Equal(t, ctrl.Result{}, result)
|
|
|
|
mockClient.AssertExpectations(t)
|
|
mockLister.AssertExpectations(t)
|
|
}
|