Files
kubemirror/pkg/controller/namespace_reconciler_test.go
T
lukaszraczylo dfe08b35d1 fix(controller): stop self-triggered reconcile loops
C2: updateLastSyncStatus wrote the sync-status annotation on every
successful reconcile. Because the source's watch predicate is the
'enabled' label (server-side filter), that Update fires a watch event
that re-enters Reconcile. With reconciled/error counts varying across
cycles, the value differs each time, so the API server bumps RV and
the loop never quiesces. Now skips the Update when the value matches
the existing annotation.

C3: NamespaceReconciler's happy-path returned RequeueAfter=3s
unconditionally. Every namespace in the cluster re-reconciled every
3 seconds forever, generating constant List calls per source kind.
Now returns ctrl.Result{}; cache-staleness windows are handled by
the manager's resync period and source freshness verification.
2026-05-02 22:39:09 +01:00

340 lines
10 KiB
Go

package controller
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/api/errors"
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"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/client/fake"
ctrl "sigs.k8s.io/controller-runtime"
"github.com/lukaszraczylo/kubemirror/pkg/config"
"github.com/lukaszraczylo/kubemirror/pkg/constants"
"github.com/lukaszraczylo/kubemirror/pkg/filter"
)
func TestNamespaceReconciler_CleanupWhenNamespaceNoLongerTarget(t *testing.T) {
scheme := runtime.NewScheme()
_ = corev1.AddToScheme(scheme)
tests := []struct {
name string
namespace *corev1.Namespace
sourceResources []*unstructured.Unstructured
existingMirrors []*unstructured.Unstructured
expectedDeleted []string // mirror names that should be deleted
expectedRemaining []string // mirror names that should remain
}{
{
name: "namespace label changes to allow-mirrors=false, mirror should be deleted",
namespace: &corev1.Namespace{
ObjectMeta: metav1.ObjectMeta{
Name: "target-ns",
Labels: map[string]string{
constants.LabelAllowMirrors: "false", // Changed to false
},
},
},
sourceResources: []*unstructured.Unstructured{
makeUnstructuredSecret("test-secret", "default", map[string]string{
constants.LabelEnabled: "true",
}, map[string]string{
constants.AnnotationSync: "true",
constants.AnnotationTargetNamespaces: "all",
}),
},
existingMirrors: []*unstructured.Unstructured{
makeUnstructuredMirror("test-secret", "target-ns", "default", "test-secret"),
},
expectedDeleted: []string{"test-secret"},
expectedRemaining: []string{},
},
{
name: "namespace no longer matches pattern, mirror should be deleted",
namespace: &corev1.Namespace{
ObjectMeta: metav1.ObjectMeta{
Name: "staging-1",
},
},
sourceResources: []*unstructured.Unstructured{
makeUnstructuredSecret("test-secret", "default", map[string]string{
constants.LabelEnabled: "true",
}, map[string]string{
constants.AnnotationSync: "true",
constants.AnnotationTargetNamespaces: "prod-*", // Pattern changed, no longer matches staging-*
}),
},
existingMirrors: []*unstructured.Unstructured{
makeUnstructuredMirror("test-secret", "staging-1", "default", "test-secret"),
},
expectedDeleted: []string{"test-secret"},
expectedRemaining: []string{},
},
{
name: "namespace becomes valid target, no existing mirror, should be created",
namespace: &corev1.Namespace{
ObjectMeta: metav1.ObjectMeta{
Name: "prod-1",
},
},
sourceResources: []*unstructured.Unstructured{
makeUnstructuredSecret("test-secret", "default", map[string]string{
constants.LabelEnabled: "true",
}, map[string]string{
constants.AnnotationSync: "true",
constants.AnnotationTargetNamespaces: "prod-*",
}),
},
existingMirrors: []*unstructured.Unstructured{},
expectedDeleted: []string{},
expectedRemaining: []string{"test-secret"}, // Should be created
},
{
name: "namespace still valid, mirror remains",
namespace: &corev1.Namespace{
ObjectMeta: metav1.ObjectMeta{
Name: "prod-1",
},
},
sourceResources: []*unstructured.Unstructured{
makeUnstructuredSecret("test-secret", "default", map[string]string{
constants.LabelEnabled: "true",
}, map[string]string{
constants.AnnotationSync: "true",
constants.AnnotationTargetNamespaces: "prod-*",
}),
},
existingMirrors: []*unstructured.Unstructured{
makeUnstructuredMirror("test-secret", "prod-1", "default", "test-secret"),
},
expectedDeleted: []string{},
expectedRemaining: []string{"test-secret"},
},
{
name: "multiple sources, only non-matching mirrors deleted",
namespace: &corev1.Namespace{
ObjectMeta: metav1.ObjectMeta{
Name: "app-1",
},
},
sourceResources: []*unstructured.Unstructured{
makeUnstructuredSecret("secret-1", "default", map[string]string{
constants.LabelEnabled: "true",
}, map[string]string{
constants.AnnotationSync: "true",
constants.AnnotationTargetNamespaces: "app-*", // Matches
}),
makeUnstructuredSecret("secret-2", "default", map[string]string{
constants.LabelEnabled: "true",
}, map[string]string{
constants.AnnotationSync: "true",
constants.AnnotationTargetNamespaces: "prod-*", // Doesn't match
}),
},
existingMirrors: []*unstructured.Unstructured{
makeUnstructuredMirror("secret-1", "app-1", "default", "secret-1"),
makeUnstructuredMirror("secret-2", "app-1", "default", "secret-2"),
},
expectedDeleted: []string{"secret-2"},
expectedRemaining: []string{"secret-1"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Create fake client with namespace, sources, and existing mirrors
objects := []client.Object{tt.namespace}
for _, src := range tt.sourceResources {
objects = append(objects, src)
}
for _, mirror := range tt.existingMirrors {
objects = append(objects, mirror)
}
fakeClient := fake.NewClientBuilder().
WithScheme(scheme).
WithObjects(objects...).
Build()
// Create namespace lister mock
mockLister := &mockNamespaceLister{
namespaces: []string{tt.namespace.Name},
allowMirrors: func() map[string]bool {
result := make(map[string]bool)
if tt.namespace.Labels[constants.LabelAllowMirrors] == "true" {
result[tt.namespace.Name] = true
}
return result
}(),
optOut: func() map[string]bool {
result := make(map[string]bool)
if tt.namespace.Labels[constants.LabelAllowMirrors] == "false" {
result[tt.namespace.Name] = true
}
return result
}(),
}
// Create reconciler
reconciler := &NamespaceReconciler{
Client: fakeClient,
Scheme: scheme,
Config: &config.Config{MaxTargetsPerResource: 100},
Filter: filter.NewNamespaceFilter([]string{"kube-system"}, []string{}),
NamespaceLister: mockLister,
ResourceTypes: []config.ResourceType{
{Group: "", Version: "v1", Kind: "Secret"},
},
}
// Reconcile the namespace
ctx := context.Background()
req := ctrl.Request{
NamespacedName: client.ObjectKey{
Name: tt.namespace.Name,
},
}
result, err := reconciler.Reconcile(ctx, req)
require.NoError(t, err)
// Regression: never schedule an unconditional re-reconcile. The
// previous implementation returned RequeueAfter=3s for every
// namespace event, which scaled to one re-reconcile per namespace
// every 3 seconds forever.
assert.Zero(t, result.RequeueAfter, "happy-path Reconcile must not schedule a periodic requeue")
// Verify mirrors were deleted as expected
for _, mirrorName := range tt.expectedDeleted {
mirror := &unstructured.Unstructured{}
mirror.SetGroupVersionKind(schema.GroupVersionKind{Version: "v1", Kind: "Secret"})
err := fakeClient.Get(ctx, client.ObjectKey{
Namespace: tt.namespace.Name,
Name: mirrorName,
}, mirror)
assert.True(t, errors.IsNotFound(err),
"mirror %s should be deleted in namespace %s", mirrorName, tt.namespace.Name)
}
// Verify mirrors remain as expected
for _, mirrorName := range tt.expectedRemaining {
mirror := &unstructured.Unstructured{}
mirror.SetGroupVersionKind(schema.GroupVersionKind{Version: "v1", Kind: "Secret"})
err := fakeClient.Get(ctx, client.ObjectKey{
Namespace: tt.namespace.Name,
Name: mirrorName,
}, mirror)
assert.NoError(t, err,
"mirror %s should exist in namespace %s", mirrorName, tt.namespace.Name)
}
})
}
}
// Helper functions
func makeUnstructuredSecret(name, namespace string, labels, annotations map[string]string) *unstructured.Unstructured {
secret := &unstructured.Unstructured{}
secret.SetGroupVersionKind(schema.GroupVersionKind{
Version: "v1",
Kind: "Secret",
})
secret.SetName(name)
secret.SetNamespace(namespace)
secret.SetLabels(labels)
secret.SetAnnotations(annotations)
// Set some data
_ = unstructured.SetNestedMap(secret.Object, map[string]interface{}{
"key": "dmFsdWU=", // base64("value")
}, "data")
return secret
}
func makeUnstructuredMirror(name, namespace, sourceNs, sourceName string) *unstructured.Unstructured {
mirror := &unstructured.Unstructured{}
mirror.SetGroupVersionKind(schema.GroupVersionKind{
Version: "v1",
Kind: "Secret",
})
mirror.SetName(name)
mirror.SetNamespace(namespace)
mirror.SetLabels(map[string]string{
constants.LabelManagedBy: "kubemirror",
constants.LabelMirror: "true",
})
mirror.SetAnnotations(map[string]string{
constants.AnnotationSourceNamespace: sourceNs,
constants.AnnotationSourceName: sourceName,
constants.AnnotationSourceUID: "test-uid",
})
// Set some data
_ = unstructured.SetNestedMap(mirror.Object, map[string]interface{}{
"key": "dmFsdWU=",
}, "data")
return mirror
}
// Mock namespace lister for testing
type mockNamespaceLister struct {
allowMirrors map[string]bool
optOut map[string]bool
namespaces []string
}
func (m *mockNamespaceLister) ListNamespaces(ctx context.Context) ([]string, error) {
return m.namespaces, nil
}
func (m *mockNamespaceLister) ListAllowMirrorsNamespaces(ctx context.Context) ([]string, error) {
var result []string
for ns, allowed := range m.allowMirrors {
if allowed {
result = append(result, ns)
}
}
return result, nil
}
func (m *mockNamespaceLister) ListOptOutNamespaces(ctx context.Context) ([]string, error) {
var result []string
for ns, optedOut := range m.optOut {
if optedOut {
result = append(result, ns)
}
}
return result, nil
}
func (m *mockNamespaceLister) ListNamespacesWithLabels(ctx context.Context) (*NamespaceInfo, error) {
info := &NamespaceInfo{
All: m.namespaces,
AllowMirrors: make([]string, 0),
OptOut: make([]string, 0),
}
for ns, allowed := range m.allowMirrors {
if allowed {
info.AllowMirrors = append(info.AllowMirrors, ns)
}
}
for ns, optedOut := range m.optOut {
if optedOut {
info.OptOut = append(info.OptOut, ns)
}
}
return info, nil
}