mirror of
https://github.com/lukaszraczylo/kubemirror.git
synced 2026-06-05 22:43:51 +00:00
CRD discovery, log noise reduction, e2e tests
This commit is contained in:
@@ -50,6 +50,10 @@ type Config struct {
|
||||
EnableAllKeyword bool
|
||||
// DryRun mode logs what would happen without actually making changes
|
||||
DryRun bool
|
||||
// VerifySourceFreshness checks cache staleness and re-fetches from API if needed
|
||||
// Prevents mirroring stale data when cache hasn't updated yet after watch event
|
||||
// Trades some API load for guaranteed data freshness
|
||||
VerifySourceFreshness bool
|
||||
}
|
||||
|
||||
// LeaderElectionConfig holds leader election settings.
|
||||
|
||||
@@ -302,18 +302,42 @@ func updateMirrorAnnotations(mirror metav1.Object, source runtime.Object, source
|
||||
}
|
||||
|
||||
// updateUnstructuredMirror updates an unstructured mirror.
|
||||
// Uses generic field introspection to handle any resource type (Secrets, ConfigMaps, CRDs).
|
||||
func updateUnstructuredMirror(mirror, source runtime.Object, sourceHash string) error {
|
||||
m := mirror.(*unstructured.Unstructured)
|
||||
s := source.(*unstructured.Unstructured)
|
||||
|
||||
// Update spec
|
||||
sourceSpec, found, err := unstructured.NestedMap(s.Object, "spec")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get source spec: %w", err)
|
||||
// Fields to skip (Kubernetes-managed fields, not user content)
|
||||
// These are managed by Kubernetes API server or controllers
|
||||
skipFields := map[string]bool{
|
||||
// Standard Kubernetes top-level fields
|
||||
"metadata": true, // Kubernetes metadata (name, namespace, labels, etc.) - managed separately
|
||||
"status": true, // Resource status - managed by controllers, never mirrored
|
||||
"apiVersion": true, // API group version - static, set during creation
|
||||
"kind": true, // Resource kind - static, set during creation
|
||||
|
||||
// Kubernetes internal fields (rarely at top level, but be defensive)
|
||||
"managedFields": true, // Field management tracking - internal to Kubernetes
|
||||
"selfLink": true, // Deprecated but might exist - auto-generated
|
||||
"resourceVersion": true, // Optimistic concurrency control - auto-generated
|
||||
"generation": true, // Spec change counter - auto-generated (but usually in metadata)
|
||||
"creationTimestamp": true, // Resource creation time - auto-generated (but usually in metadata)
|
||||
"deletionTimestamp": true, // Resource deletion time - auto-generated (but usually in metadata)
|
||||
"deletionGracePeriodSeconds": true, // Grace period - auto-managed (but usually in metadata)
|
||||
"uid": true, // Unique identifier - auto-generated (but usually in metadata)
|
||||
"ownerReferences": true, // Ownership chain - should not be copied (but usually in metadata)
|
||||
"finalizers": true, // Deletion hooks - should not be copied (but usually in metadata)
|
||||
}
|
||||
if found {
|
||||
if err := unstructured.SetNestedMap(m.Object, sourceSpec, "spec"); err != nil {
|
||||
return fmt.Errorf("failed to set mirror spec: %w", err)
|
||||
|
||||
// Copy all content fields from source to mirror
|
||||
// This handles:
|
||||
// - .spec (standard CRDs like Traefik Middleware)
|
||||
// - .data, .type (Secrets)
|
||||
// - .data, .binaryData (ConfigMaps)
|
||||
// - Any custom top-level fields in non-standard CRDs
|
||||
for key, value := range s.Object {
|
||||
if !skipFields[key] {
|
||||
m.Object[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -368,6 +368,130 @@ func TestUpdateMirror_ConfigMap(t *testing.T) {
|
||||
assert.NotEqual(t, "oldhash", mirror.Annotations[constants.AnnotationSourceContentHash])
|
||||
}
|
||||
|
||||
func TestUpdateMirror_UnstructuredSecret(t *testing.T) {
|
||||
// This test validates the fix for the bug where Unstructured Secrets
|
||||
// would update annotations but not data fields during UpdateMirror
|
||||
mirror := &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",
|
||||
},
|
||||
"annotations": map[string]interface{}{
|
||||
constants.AnnotationSourceContentHash: "oldhash",
|
||||
},
|
||||
},
|
||||
"type": "Opaque",
|
||||
"data": map[string]interface{}{
|
||||
"password": "b2xkLXZhbHVl", // base64 encoded "old-value"
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
source := &unstructured.Unstructured{
|
||||
Object: map[string]interface{}{
|
||||
"apiVersion": "v1",
|
||||
"kind": "Secret",
|
||||
"metadata": map[string]interface{}{
|
||||
"name": "test-secret",
|
||||
"namespace": "default",
|
||||
"generation": int64(10),
|
||||
},
|
||||
"type": "kubernetes.io/tls",
|
||||
"data": map[string]interface{}{
|
||||
"password": "bmV3LXZhbHVl", // base64 encoded "new-value"
|
||||
"username": "YWRtaW4=", // base64 encoded "admin"
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
err := UpdateMirror(mirror, source)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify data was updated (this was the bug - data wasn't being updated)
|
||||
mirrorData, found, err := unstructured.NestedMap(mirror.Object, "data")
|
||||
require.NoError(t, err)
|
||||
require.True(t, found, "mirror should have data field")
|
||||
sourceData, _, _ := unstructured.NestedMap(source.Object, "data")
|
||||
assert.Equal(t, sourceData, mirrorData, "mirror data should match source data")
|
||||
|
||||
// Verify type was updated
|
||||
mirrorType, found, err := unstructured.NestedString(mirror.Object, "type")
|
||||
require.NoError(t, err)
|
||||
require.True(t, found, "mirror should have type field")
|
||||
assert.Equal(t, "kubernetes.io/tls", mirrorType, "mirror type should be updated")
|
||||
|
||||
// Verify annotations were updated
|
||||
annotations := mirror.GetAnnotations()
|
||||
assert.NotEqual(t, "oldhash", annotations[constants.AnnotationSourceContentHash], "hash should be updated")
|
||||
assert.Equal(t, "10", annotations[constants.AnnotationSourceGeneration], "generation should be updated")
|
||||
assert.NotEmpty(t, annotations[constants.AnnotationLastSyncTime], "sync time should be set")
|
||||
}
|
||||
|
||||
func TestUpdateMirror_UnstructuredConfigMap(t *testing.T) {
|
||||
// Test Unstructured ConfigMap to ensure data and binaryData are updated
|
||||
mirror := &unstructured.Unstructured{
|
||||
Object: map[string]interface{}{
|
||||
"apiVersion": "v1",
|
||||
"kind": "ConfigMap",
|
||||
"metadata": map[string]interface{}{
|
||||
"name": "test-config",
|
||||
"namespace": "app1",
|
||||
"annotations": map[string]interface{}{
|
||||
constants.AnnotationSourceContentHash: "oldhash",
|
||||
},
|
||||
},
|
||||
"data": map[string]interface{}{
|
||||
"key": "old-value",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
source := &unstructured.Unstructured{
|
||||
Object: map[string]interface{}{
|
||||
"apiVersion": "v1",
|
||||
"kind": "ConfigMap",
|
||||
"metadata": map[string]interface{}{
|
||||
"name": "test-config",
|
||||
"namespace": "default",
|
||||
},
|
||||
"data": map[string]interface{}{
|
||||
"key": "new-value",
|
||||
"key2": "another-value",
|
||||
},
|
||||
"binaryData": map[string]interface{}{
|
||||
"binary": "AAECAwQ=", // base64 binary data
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
err := UpdateMirror(mirror, source)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify data was updated
|
||||
mirrorData, found, err := unstructured.NestedMap(mirror.Object, "data")
|
||||
require.NoError(t, err)
|
||||
require.True(t, found, "mirror should have data field")
|
||||
sourceData, _, _ := unstructured.NestedMap(source.Object, "data")
|
||||
assert.Equal(t, sourceData, mirrorData, "mirror data should match source data")
|
||||
|
||||
// Verify binaryData was updated
|
||||
mirrorBinaryData, found, err := unstructured.NestedMap(mirror.Object, "binaryData")
|
||||
require.NoError(t, err)
|
||||
require.True(t, found, "mirror should have binaryData field")
|
||||
sourceBinaryData, _, _ := unstructured.NestedMap(source.Object, "binaryData")
|
||||
assert.Equal(t, sourceBinaryData, mirrorBinaryData, "mirror binaryData should match source binaryData")
|
||||
|
||||
// Verify annotations were updated
|
||||
annotations := mirror.GetAnnotations()
|
||||
assert.NotEqual(t, "oldhash", annotations[constants.AnnotationSourceContentHash], "hash should be updated")
|
||||
}
|
||||
|
||||
func TestIsManagedByUs(t *testing.T) {
|
||||
tests := []struct {
|
||||
obj metav1.Object
|
||||
|
||||
@@ -55,3 +55,23 @@ func (k *KubernetesNamespaceLister) ListAllowMirrorsNamespaces(ctx context.Conte
|
||||
|
||||
return names, nil
|
||||
}
|
||||
|
||||
// ListOptOutNamespaces returns namespaces that have explicitly opted out of mirrors.
|
||||
// These are namespaces with allow-mirrors="false".
|
||||
func (k *KubernetesNamespaceLister) ListOptOutNamespaces(ctx context.Context) ([]string, error) {
|
||||
namespaceList := &corev1.NamespaceList{}
|
||||
|
||||
// List namespaces with allow-mirrors label set to false
|
||||
if err := k.client.List(ctx, namespaceList, client.MatchingLabels{
|
||||
constants.LabelAllowMirrors: "false",
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
names := make([]string, 0, len(namespaceList.Items))
|
||||
for _, ns := range namespaceList.Items {
|
||||
names = append(names, ns.Name)
|
||||
}
|
||||
|
||||
return names, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
// Package controller implements the kubemirror reconciliation logic.
|
||||
package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
"k8s.io/apimachinery/pkg/api/errors"
|
||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
ctrl "sigs.k8s.io/controller-runtime"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||
"sigs.k8s.io/controller-runtime/pkg/event"
|
||||
"sigs.k8s.io/controller-runtime/pkg/log"
|
||||
"sigs.k8s.io/controller-runtime/pkg/predicate"
|
||||
|
||||
"github.com/lukaszraczylo/kubemirror/pkg/config"
|
||||
"github.com/lukaszraczylo/kubemirror/pkg/constants"
|
||||
"github.com/lukaszraczylo/kubemirror/pkg/filter"
|
||||
)
|
||||
|
||||
// NamespaceReconciler watches for namespace CREATE and UPDATE events
|
||||
// and triggers reconciliation of source resources that match the new namespace.
|
||||
type NamespaceReconciler struct {
|
||||
client.Client
|
||||
Scheme *runtime.Scheme
|
||||
Config *config.Config
|
||||
Filter *filter.NamespaceFilter
|
||||
NamespaceLister NamespaceLister
|
||||
// ResourceTypes contains all discovered resource types to reconcile
|
||||
ResourceTypes []config.ResourceType
|
||||
}
|
||||
|
||||
// Reconcile processes namespace events and creates mirrors for matching sources.
|
||||
func (r *NamespaceReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
|
||||
logger := log.FromContext(ctx).WithValues("namespace", req.Name)
|
||||
|
||||
// Fetch the namespace
|
||||
namespace := &corev1.Namespace{}
|
||||
if err := r.Get(ctx, req.NamespacedName, namespace); err != nil {
|
||||
// Namespace was deleted - nothing to do (source reconcilers will handle cleanup)
|
||||
return ctrl.Result{}, client.IgnoreNotFound(err)
|
||||
}
|
||||
|
||||
// Skip system namespaces
|
||||
if r.Filter != nil && !r.Filter.IsAllowed(namespace.Name) {
|
||||
logger.V(1).Info("namespace filtered out, skipping")
|
||||
return ctrl.Result{}, nil
|
||||
}
|
||||
|
||||
logger.Info("namespace event detected, reconciling source resources")
|
||||
|
||||
// Query all source resources that have mirroring enabled
|
||||
// For each resource type, find resources with the sync annotation
|
||||
var totalReconciled, totalErrors int
|
||||
|
||||
for _, rt := range r.ResourceTypes {
|
||||
reconciled, errors, err := r.reconcileResourceType(ctx, rt, namespace.Name)
|
||||
if err != nil {
|
||||
logger.Error(err, "failed to reconcile resource type",
|
||||
"group", rt.Group, "version", rt.Version, "kind", rt.Kind)
|
||||
totalErrors++
|
||||
continue
|
||||
}
|
||||
totalReconciled += reconciled
|
||||
totalErrors += errors
|
||||
}
|
||||
|
||||
logger.Info("namespace reconciliation complete",
|
||||
"reconciled", totalReconciled,
|
||||
"errors", totalErrors,
|
||||
"resourceTypes", len(r.ResourceTypes))
|
||||
|
||||
if totalErrors > 0 {
|
||||
return ctrl.Result{}, fmt.Errorf("failed to reconcile %d source resources", totalErrors)
|
||||
}
|
||||
|
||||
return ctrl.Result{}, nil
|
||||
}
|
||||
|
||||
// reconcileResourceType finds and reconciles all sources of a specific resource type
|
||||
// that match the namespace.
|
||||
func (r *NamespaceReconciler) reconcileResourceType(ctx context.Context, rt config.ResourceType, namespaceName string) (int, int, error) {
|
||||
logger := log.FromContext(ctx)
|
||||
|
||||
gvk := rt.GroupVersionKind()
|
||||
|
||||
// List all resources of this type with the enabled label
|
||||
// Using label selector for server-side filtering
|
||||
list := &unstructured.UnstructuredList{}
|
||||
list.SetGroupVersionKind(gvk)
|
||||
|
||||
listOpts := []client.ListOption{
|
||||
client.HasLabels{constants.LabelEnabled},
|
||||
}
|
||||
|
||||
if err := r.List(ctx, list, listOpts...); err != nil {
|
||||
return 0, 0, fmt.Errorf("failed to list resources: %w", err)
|
||||
}
|
||||
|
||||
var reconciledCount, errorCount int
|
||||
|
||||
for i := range list.Items {
|
||||
source := &list.Items[i]
|
||||
|
||||
// Check if source has sync annotation
|
||||
annotations := source.GetAnnotations()
|
||||
if annotations == nil || annotations[constants.AnnotationSync] != "true" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip if this is a mirror resource itself
|
||||
if IsMirrorResource(source) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Resolve target namespaces for this source
|
||||
targetNamespaces, err := r.resolveTargetNamespaces(ctx, source)
|
||||
if err != nil {
|
||||
logger.Error(err, "failed to resolve target namespaces",
|
||||
"source", source.GetName(), "namespace", source.GetNamespace())
|
||||
errorCount++
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if the new namespace matches this source's targets
|
||||
var isTarget bool
|
||||
for _, target := range targetNamespaces {
|
||||
if target == namespaceName {
|
||||
isTarget = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if isTarget {
|
||||
// Create or update mirror in the namespace
|
||||
if err := r.reconcileMirror(ctx, source, namespaceName); err != nil {
|
||||
logger.Error(err, "failed to create mirror",
|
||||
"source", source.GetName(),
|
||||
"sourceNamespace", source.GetNamespace(),
|
||||
"targetNamespace", namespaceName)
|
||||
errorCount++
|
||||
continue
|
||||
}
|
||||
|
||||
reconciledCount++
|
||||
logger.V(1).Info("mirror created/updated for namespace",
|
||||
"source", source.GetName(),
|
||||
"sourceNamespace", source.GetNamespace(),
|
||||
"targetNamespace", namespaceName,
|
||||
"resourceType", rt.String())
|
||||
} else {
|
||||
// Namespace is no longer a target - check if mirror exists and delete it
|
||||
mirror := &unstructured.Unstructured{}
|
||||
mirror.SetGroupVersionKind(source.GroupVersionKind())
|
||||
mirror.SetNamespace(namespaceName)
|
||||
mirror.SetName(source.GetName())
|
||||
|
||||
err := r.Get(ctx, client.ObjectKey{Namespace: namespaceName, Name: source.GetName()}, mirror)
|
||||
if errors.IsNotFound(err) {
|
||||
// No mirror exists, nothing to clean up
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
logger.Error(err, "failed to check for mirror",
|
||||
"source", source.GetName(),
|
||||
"namespace", namespaceName)
|
||||
errorCount++
|
||||
continue
|
||||
}
|
||||
|
||||
// Verify this is actually our mirror (not someone else's resource with the same name)
|
||||
if !IsManagedByUs(mirror) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Verify this mirror points to our source
|
||||
srcNs, srcName, _, found := GetSourceReference(mirror)
|
||||
if !found || srcNs != source.GetNamespace() || srcName != source.GetName() {
|
||||
continue
|
||||
}
|
||||
|
||||
// This mirror should be deleted (namespace no longer a valid target)
|
||||
if err := r.Delete(ctx, mirror); err != nil {
|
||||
logger.Error(err, "failed to delete orphaned mirror",
|
||||
"source", source.GetName(),
|
||||
"sourceNamespace", source.GetNamespace(),
|
||||
"targetNamespace", namespaceName)
|
||||
errorCount++
|
||||
continue
|
||||
}
|
||||
|
||||
reconciledCount++
|
||||
logger.V(1).Info("deleted orphaned mirror due to namespace label change",
|
||||
"source", source.GetName(),
|
||||
"sourceNamespace", source.GetNamespace(),
|
||||
"targetNamespace", namespaceName,
|
||||
"resourceType", rt.String())
|
||||
}
|
||||
}
|
||||
|
||||
return reconciledCount, errorCount, nil
|
||||
}
|
||||
|
||||
// resolveTargetNamespaces determines which namespaces should receive mirrors for a source.
|
||||
// Uses the same logic as SourceReconciler.resolveTargetNamespaces.
|
||||
func (r *NamespaceReconciler) resolveTargetNamespaces(ctx context.Context, source *unstructured.Unstructured) ([]string, error) {
|
||||
annotations := source.GetAnnotations()
|
||||
if annotations == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
targetNsAnnotation := annotations[constants.AnnotationTargetNamespaces]
|
||||
if targetNsAnnotation == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Parse patterns
|
||||
patterns := filter.ParseTargetNamespaces(targetNsAnnotation)
|
||||
if len(patterns) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Get all namespaces
|
||||
allNamespaces, err := r.NamespaceLister.ListNamespaces(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list namespaces: %w", err)
|
||||
}
|
||||
|
||||
// Get namespaces with allow-mirrors label
|
||||
allowMirrorsNamespaces, err := r.NamespaceLister.ListAllowMirrorsNamespaces(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list allow-mirrors namespaces: %w", err)
|
||||
}
|
||||
|
||||
// Get namespaces that have explicitly opted out (allow-mirrors="false")
|
||||
optOutNamespaces, err := r.NamespaceLister.ListOptOutNamespaces(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list opt-out namespaces: %w", err)
|
||||
}
|
||||
|
||||
// Resolve target namespaces
|
||||
targetNamespaces := filter.ResolveTargetNamespaces(
|
||||
patterns,
|
||||
allNamespaces,
|
||||
allowMirrorsNamespaces,
|
||||
optOutNamespaces,
|
||||
source.GetNamespace(),
|
||||
r.Filter,
|
||||
)
|
||||
|
||||
// Enforce max targets limit
|
||||
if r.Config != nil && r.Config.MaxTargetsPerResource > 0 && len(targetNamespaces) > r.Config.MaxTargetsPerResource {
|
||||
targetNamespaces = targetNamespaces[:r.Config.MaxTargetsPerResource]
|
||||
}
|
||||
|
||||
return targetNamespaces, nil
|
||||
}
|
||||
|
||||
// reconcileMirror creates or updates a mirror in the target namespace.
|
||||
// This calls the mirror creation logic from the SourceReconciler.
|
||||
func (r *NamespaceReconciler) reconcileMirror(ctx context.Context, source *unstructured.Unstructured, targetNamespace string) error {
|
||||
// Create a temporary SourceReconciler to use its mirror creation logic
|
||||
// This avoids code duplication
|
||||
sourceReconciler := &SourceReconciler{
|
||||
Client: r.Client,
|
||||
Scheme: r.Scheme,
|
||||
Config: r.Config,
|
||||
Filter: r.Filter,
|
||||
NamespaceLister: r.NamespaceLister,
|
||||
GVK: source.GroupVersionKind(),
|
||||
}
|
||||
|
||||
return sourceReconciler.reconcileMirror(ctx, source, source, targetNamespace)
|
||||
}
|
||||
|
||||
// SetupWithManager sets up the controller with the Manager.
|
||||
func (r *NamespaceReconciler) SetupWithManager(mgr ctrl.Manager) error {
|
||||
// Create predicate to only watch for relevant namespace events
|
||||
namespacePredicate := predicate.Funcs{
|
||||
CreateFunc: func(e event.CreateEvent) bool {
|
||||
// Always reconcile new namespaces
|
||||
return true
|
||||
},
|
||||
UpdateFunc: func(e event.UpdateEvent) bool {
|
||||
// Only reconcile if labels changed (specifically allow-mirrors label)
|
||||
oldNs, okOld := e.ObjectOld.(*corev1.Namespace)
|
||||
newNs, okNew := e.ObjectNew.(*corev1.Namespace)
|
||||
if !okOld || !okNew {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if allow-mirrors label changed
|
||||
oldLabel := oldNs.Labels[constants.LabelAllowMirrors]
|
||||
newLabel := newNs.Labels[constants.LabelAllowMirrors]
|
||||
|
||||
return oldLabel != newLabel
|
||||
},
|
||||
DeleteFunc: func(e event.DeleteEvent) bool {
|
||||
// Don't reconcile on delete - source reconcilers will handle cleanup via finalizers
|
||||
return false
|
||||
},
|
||||
}
|
||||
|
||||
return ctrl.NewControllerManagedBy(mgr).
|
||||
For(&corev1.Namespace{}).
|
||||
WithEventFilter(namespacePredicate).
|
||||
Complete(r)
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
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,
|
||||
},
|
||||
}
|
||||
_, err := reconciler.Reconcile(ctx, req)
|
||||
require.NoError(t, err)
|
||||
|
||||
// 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 {
|
||||
namespaces []string
|
||||
allowMirrors map[string]bool
|
||||
optOut map[string]bool
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -36,6 +36,7 @@ type SourceReconciler struct {
|
||||
Filter *filter.NamespaceFilter
|
||||
NamespaceLister NamespaceLister
|
||||
GVK schema.GroupVersionKind // The resource type this reconciler handles
|
||||
APIReader client.Reader // Direct API reader (bypasses cache)
|
||||
}
|
||||
|
||||
// NamespaceLister provides a list of all namespaces in the cluster.
|
||||
@@ -43,20 +44,83 @@ type SourceReconciler struct {
|
||||
type NamespaceLister interface {
|
||||
ListNamespaces(ctx context.Context) ([]string, error)
|
||||
ListAllowMirrorsNamespaces(ctx context.Context) ([]string, error)
|
||||
ListOptOutNamespaces(ctx context.Context) ([]string, error)
|
||||
}
|
||||
|
||||
// +kubebuilder:rbac:groups=core,resources=secrets,verbs=get;list;watch;create;update;patch;delete
|
||||
// +kubebuilder:rbac:groups=core,resources=configmaps,verbs=get;list;watch;create;update;patch;delete
|
||||
// +kubebuilder:rbac:groups=core,resources=namespaces,verbs=get;list;watch
|
||||
|
||||
// getSourceWithFreshness fetches a source resource with optional freshness verification.
|
||||
// This implements a hybrid caching strategy:
|
||||
// 1. First read from informer cache (fast, local)
|
||||
// 2. If VerifySourceFreshness is enabled, make direct API call via APIReader
|
||||
// 3. If resourceVersions differ, cache is stale - return fresh version from API
|
||||
// 4. If resourceVersions match, cache is current - return cached version
|
||||
//
|
||||
// This prevents the race condition where:
|
||||
// - Watch event arrives: "Secret changed!"
|
||||
// - Reconciliation starts immediately
|
||||
// - Cache hasn't updated yet (5-20 second lag)
|
||||
// - We read stale data and mirror it
|
||||
//
|
||||
// Trade-off: 2x API calls when cache is stale, but guarantees data freshness.
|
||||
func (r *SourceReconciler) getSourceWithFreshness(ctx context.Context, key client.ObjectKey, gvk schema.GroupVersionKind) (*unstructured.Unstructured, error) {
|
||||
logger := log.FromContext(ctx)
|
||||
|
||||
// First try: Read from cache (fast)
|
||||
cached := &unstructured.Unstructured{}
|
||||
cached.SetGroupVersionKind(gvk)
|
||||
if err := r.Get(ctx, key, cached); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// If freshness verification is disabled, return cached version immediately
|
||||
if !r.Config.VerifySourceFreshness {
|
||||
logger.V(2).Info("using cached source (freshness check disabled)", "resourceVersion", cached.GetResourceVersion())
|
||||
return cached, nil
|
||||
}
|
||||
|
||||
// If APIReader is not available (e.g., in tests), fall back to cached version
|
||||
if r.APIReader == nil {
|
||||
logger.V(2).Info("using cached source (no APIReader available)", "resourceVersion", cached.GetResourceVersion())
|
||||
return cached, nil
|
||||
}
|
||||
|
||||
cachedRV := cached.GetResourceVersion()
|
||||
|
||||
// Second try: Direct API read to verify freshness (bypasses cache)
|
||||
fresh := &unstructured.Unstructured{}
|
||||
fresh.SetGroupVersionKind(gvk)
|
||||
if err := r.APIReader.Get(ctx, key, fresh); err != nil {
|
||||
// If direct API read fails, fall back to cached version
|
||||
logger.V(1).Info("direct API read failed, using cached version", "error", err, "cachedRV", cachedRV)
|
||||
return cached, nil
|
||||
}
|
||||
|
||||
freshRV := fresh.GetResourceVersion()
|
||||
|
||||
// Compare resource versions
|
||||
if cachedRV != freshRV {
|
||||
// Cache is stale - return fresh version from API
|
||||
logger.V(1).Info("cache stale, using fresh API version",
|
||||
"cachedRV", cachedRV,
|
||||
"freshRV", freshRV)
|
||||
return fresh, nil
|
||||
}
|
||||
|
||||
// Cache is current - return cached version (saves memory allocation)
|
||||
logger.V(2).Info("cache current", "resourceVersion", cachedRV)
|
||||
return cached, nil
|
||||
}
|
||||
|
||||
// Reconcile processes a single source resource.
|
||||
func (r *SourceReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
|
||||
logger := log.FromContext(ctx).WithValues("namespace", req.Namespace, "name", req.Name)
|
||||
|
||||
// Fetch the source resource as unstructured (works for all resource types)
|
||||
source := &unstructured.Unstructured{}
|
||||
source.SetGroupVersionKind(r.GVK) // Set the GVK so the client knows what to fetch
|
||||
if err := r.Get(ctx, req.NamespacedName, source); err != nil {
|
||||
// Fetch the source resource with optional freshness verification
|
||||
source, err := r.getSourceWithFreshness(ctx, req.NamespacedName, r.GVK)
|
||||
if err != nil {
|
||||
if errors.IsNotFound(err) {
|
||||
// Resource deleted - nothing to do
|
||||
return ctrl.Result{}, nil
|
||||
@@ -216,10 +280,24 @@ func (r *SourceReconciler) reconcileMirror(ctx context.Context, source runtime.O
|
||||
return fmt.Errorf("failed to get existing mirror: %w", err)
|
||||
}
|
||||
|
||||
// If freshness verification is enabled and mirror exists, verify it's fresh too
|
||||
if err == nil && r.Config.VerifySourceFreshness && r.APIReader != nil {
|
||||
fresh := &unstructured.Unstructured{}
|
||||
fresh.SetGroupVersionKind(sourceUnstructured.GroupVersionKind())
|
||||
if apiErr := r.APIReader.Get(ctx, client.ObjectKey{Namespace: targetNs, Name: sourceObj.GetName()}, fresh); apiErr == nil {
|
||||
if fresh.GetResourceVersion() != existing.GetResourceVersion() {
|
||||
logger.V(2).Info("mirror cache stale, using fresh API version",
|
||||
"cachedRV", existing.GetResourceVersion(),
|
||||
"freshRV", fresh.GetResourceVersion())
|
||||
existing = fresh
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
// Mirror exists - check if it's managed by us
|
||||
if !IsManagedByUs(existing) {
|
||||
logger.Info("target resource exists but not managed by kubemirror, skipping")
|
||||
logger.V(1).Info("target resource exists but not managed by kubemirror, skipping")
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -230,7 +308,7 @@ func (r *SourceReconciler) reconcileMirror(ctx context.Context, source runtime.O
|
||||
}
|
||||
|
||||
if !needsSync {
|
||||
logger.V(1).Info("mirror is up to date")
|
||||
logger.V(2).Info("mirror is up to date")
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -243,7 +321,7 @@ func (r *SourceReconciler) reconcileMirror(ctx context.Context, source runtime.O
|
||||
return fmt.Errorf("failed to update mirror in cluster: %w", err)
|
||||
}
|
||||
|
||||
logger.Info("mirror updated")
|
||||
logger.V(1).Info("mirror updated")
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -257,7 +335,7 @@ func (r *SourceReconciler) reconcileMirror(ctx context.Context, source runtime.O
|
||||
return fmt.Errorf("failed to create mirror in cluster: %w", err)
|
||||
}
|
||||
|
||||
logger.Info("mirror created")
|
||||
logger.V(1).Info("mirror created")
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -407,11 +485,18 @@ func (r *SourceReconciler) resolveTargetNamespaces(ctx context.Context, sourceOb
|
||||
return nil, fmt.Errorf("failed to list allow-mirrors namespaces: %w", err)
|
||||
}
|
||||
|
||||
// Get namespaces that have explicitly opted out (allow-mirrors="false")
|
||||
optOutNamespaces, err := r.NamespaceLister.ListOptOutNamespaces(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list opt-out namespaces: %w", err)
|
||||
}
|
||||
|
||||
// Resolve target namespaces
|
||||
targetNamespaces := filter.ResolveTargetNamespaces(
|
||||
patterns,
|
||||
allNamespaces,
|
||||
allowMirrorsNamespaces,
|
||||
optOutNamespaces,
|
||||
sourceObj.GetNamespace(),
|
||||
r.Filter,
|
||||
)
|
||||
|
||||
@@ -129,6 +129,11 @@ func (m *MockNamespaceLister) ListAllowMirrorsNamespaces(ctx context.Context) ([
|
||||
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
|
||||
@@ -277,6 +282,7 @@ func TestSourceReconciler_resolveTargetNamespaces(t *testing.T) {
|
||||
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{
|
||||
@@ -441,6 +447,7 @@ func BenchmarkResolveTargetNamespaces(b *testing.B) {
|
||||
}
|
||||
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{},
|
||||
@@ -616,6 +623,7 @@ func TestSourceReconciler_Reconcile_AnnotationChange_AllToAllLabeled(t *testing.
|
||||
|
||||
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).
|
||||
@@ -739,6 +747,7 @@ func TestSourceReconciler_Reconcile_AnnotationChange_PatternChange(t *testing.T)
|
||||
|
||||
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).
|
||||
|
||||
+14
-3
@@ -105,6 +105,7 @@ func ParseTargetNamespaces(value string) []string {
|
||||
// - patterns: namespace patterns from annotation
|
||||
// - allNamespaces: list of all namespaces in cluster
|
||||
// - allowMirrorsNamespaces: namespaces with allow-mirrors label
|
||||
// - optOutNamespaces: namespaces with allow-mirrors="false" (explicitly opted out)
|
||||
// - sourceNamespace: exclude this namespace to prevent self-copy
|
||||
// - filter: namespace filter for exclusions
|
||||
//
|
||||
@@ -113,6 +114,7 @@ func ResolveTargetNamespaces(
|
||||
patterns []string,
|
||||
allNamespaces []string,
|
||||
allowMirrorsNamespaces []string,
|
||||
optOutNamespaces []string,
|
||||
sourceNamespace string,
|
||||
filter *NamespaceFilter,
|
||||
) []string {
|
||||
@@ -120,21 +122,30 @@ func ResolveTargetNamespaces(
|
||||
return nil
|
||||
}
|
||||
|
||||
// Create map of opt-out namespaces for fast lookup
|
||||
optOutMap := make(map[string]bool)
|
||||
for _, ns := range optOutNamespaces {
|
||||
optOutMap[ns] = true
|
||||
}
|
||||
|
||||
// Use map to deduplicate
|
||||
targetMap := make(map[string]bool)
|
||||
|
||||
for _, pattern := range patterns {
|
||||
switch pattern {
|
||||
case constants.TargetNamespacesAll:
|
||||
// Mirror to all namespaces (except source and excluded)
|
||||
// Mirror to all namespaces (except source, excluded, and opt-out)
|
||||
// This implements opt-OUT model: namespaces without labels get mirrors
|
||||
// Only namespaces with allow-mirrors="false" are excluded
|
||||
for _, ns := range allNamespaces {
|
||||
if ns != sourceNamespace && filter.IsAllowed(ns) {
|
||||
if ns != sourceNamespace && filter.IsAllowed(ns) && !optOutMap[ns] {
|
||||
targetMap[ns] = true
|
||||
}
|
||||
}
|
||||
|
||||
case constants.TargetNamespacesAllLabeled:
|
||||
// Mirror only to namespaces with allow-mirrors label
|
||||
// Mirror only to namespaces with allow-mirrors="true" label
|
||||
// This implements opt-IN model
|
||||
for _, ns := range allowMirrorsNamespaces {
|
||||
if ns != sourceNamespace && filter.IsAllowed(ns) {
|
||||
targetMap[ns] = true
|
||||
|
||||
@@ -318,6 +318,7 @@ func TestResolveTargetNamespaces(t *testing.T) {
|
||||
tt.patterns,
|
||||
tt.allNamespaces,
|
||||
tt.allowMirrorsNamespaces,
|
||||
[]string{}, // optOutNamespaces - empty for these tests
|
||||
tt.sourceNamespace,
|
||||
tt.filter,
|
||||
)
|
||||
@@ -342,6 +343,7 @@ func TestResolveTargetNamespaces_EdgeCases(t *testing.T) {
|
||||
[]string{"all"},
|
||||
[]string{},
|
||||
[]string{},
|
||||
[]string{}, // optOutNamespaces
|
||||
"default",
|
||||
NewNamespaceFilter([]string{}, []string{}),
|
||||
)
|
||||
@@ -354,6 +356,7 @@ func TestResolveTargetNamespaces_EdgeCases(t *testing.T) {
|
||||
[]string{"[invalid"},
|
||||
[]string{"app1"},
|
||||
[]string{},
|
||||
[]string{}, // optOutNamespaces
|
||||
"default",
|
||||
NewNamespaceFilter([]string{}, []string{}),
|
||||
)
|
||||
@@ -366,6 +369,7 @@ func TestResolveTargetNamespaces_EdgeCases(t *testing.T) {
|
||||
[]string{"all"},
|
||||
[]string{"app1", "app2", "app3"},
|
||||
[]string{},
|
||||
[]string{}, // optOutNamespaces
|
||||
"default",
|
||||
strictFilter,
|
||||
)
|
||||
@@ -541,6 +545,7 @@ func BenchmarkResolveTargetNamespaces(b *testing.B) {
|
||||
tt.patterns,
|
||||
allNamespaces,
|
||||
allowMirrorsNamespaces,
|
||||
[]string{}, // optOutNamespaces
|
||||
"default",
|
||||
filter,
|
||||
)
|
||||
@@ -566,6 +571,7 @@ func BenchmarkResolveTargetNamespaces_LargeScale(b *testing.B) {
|
||||
[]string{constants.TargetNamespacesAll},
|
||||
allNamespaces,
|
||||
allowMirrorsNamespaces,
|
||||
[]string{}, // optOutNamespaces
|
||||
"default",
|
||||
filter,
|
||||
)
|
||||
@@ -579,6 +585,7 @@ func BenchmarkResolveTargetNamespaces_LargeScale(b *testing.B) {
|
||||
[]string{"namespace-*"},
|
||||
allNamespaces,
|
||||
allowMirrorsNamespaces,
|
||||
[]string{}, // optOutNamespaces
|
||||
"default",
|
||||
filter,
|
||||
)
|
||||
|
||||
+4
-2
@@ -10,6 +10,8 @@ import (
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
|
||||
"github.com/lukaszraczylo/kubemirror/pkg/constants"
|
||||
)
|
||||
|
||||
// ComputeContentHash computes a SHA256 hash of the resource's actual content.
|
||||
@@ -109,7 +111,7 @@ func NeedsSync(source, target runtime.Object, targetAnnotations map[string]strin
|
||||
// Layer 1: Generation-based check (for resources that support it)
|
||||
sourceGen := getGeneration(source)
|
||||
if sourceGen > 0 {
|
||||
targetSourceGen := targetAnnotations["source-generation"]
|
||||
targetSourceGen := targetAnnotations[constants.AnnotationSourceGeneration]
|
||||
if fmt.Sprintf("%d", sourceGen) != targetSourceGen {
|
||||
return true, nil // Generation changed
|
||||
}
|
||||
@@ -121,7 +123,7 @@ func NeedsSync(source, target runtime.Object, targetAnnotations map[string]strin
|
||||
return false, fmt.Errorf("failed to compute source hash: %w", err)
|
||||
}
|
||||
|
||||
targetSourceHash := targetAnnotations["source-content-hash"]
|
||||
targetSourceHash := targetAnnotations[constants.AnnotationSourceContentHash]
|
||||
if sourceHash != targetSourceHash {
|
||||
return true, nil // Content changed
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package hash
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/lukaszraczylo/kubemirror/pkg/constants"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
@@ -374,8 +375,8 @@ func TestNeedsSync(t *testing.T) {
|
||||
},
|
||||
target: &unstructured.Unstructured{},
|
||||
targetAnnotations: map[string]string{
|
||||
"source-generation": "3",
|
||||
"source-content-hash": "abc123",
|
||||
constants.AnnotationSourceGeneration: "3",
|
||||
constants.AnnotationSourceContentHash: "abc123",
|
||||
},
|
||||
want: true,
|
||||
wantError: false,
|
||||
@@ -387,8 +388,8 @@ func TestNeedsSync(t *testing.T) {
|
||||
},
|
||||
target: &corev1.Secret{},
|
||||
targetAnnotations: map[string]string{
|
||||
"source-generation": "0",
|
||||
"source-content-hash": mustComputeHash(t, &corev1.Secret{Data: map[string][]byte{"key": []byte("value")}}),
|
||||
constants.AnnotationSourceGeneration: "0",
|
||||
constants.AnnotationSourceContentHash: mustComputeHash(t, &corev1.Secret{Data: map[string][]byte{"key": []byte("value")}}),
|
||||
},
|
||||
want: false,
|
||||
wantError: false,
|
||||
@@ -400,7 +401,7 @@ func TestNeedsSync(t *testing.T) {
|
||||
},
|
||||
target: &corev1.ConfigMap{},
|
||||
targetAnnotations: map[string]string{
|
||||
"source-content-hash": "oldhash",
|
||||
constants.AnnotationSourceContentHash: "oldhash",
|
||||
},
|
||||
want: true,
|
||||
wantError: false,
|
||||
|
||||
Reference in New Issue
Block a user