mirror of
https://github.com/lukaszraczylo/kubemirror.git
synced 2026-06-11 23:19:54 +00:00
initial commit
This commit is contained in:
@@ -0,0 +1,277 @@
|
||||
// Package controller implements the kubemirror reconciliation logic.
|
||||
package controller
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
|
||||
"github.com/lukaszraczylo/kubemirror/pkg/constants"
|
||||
"github.com/lukaszraczylo/kubemirror/pkg/hash"
|
||||
)
|
||||
|
||||
// CreateMirror creates a mirror resource in the target namespace.
|
||||
// It copies the source resource's spec/data and adds ownership annotations.
|
||||
func CreateMirror(source runtime.Object, targetNamespace string) (runtime.Object, error) {
|
||||
// Compute content hash of source
|
||||
sourceHash, err := hash.ComputeContentHash(source)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to compute source hash: %w", err)
|
||||
}
|
||||
|
||||
// Handle typed resources
|
||||
switch src := source.(type) {
|
||||
case *corev1.Secret:
|
||||
return createSecretMirror(src, targetNamespace, sourceHash)
|
||||
case *corev1.ConfigMap:
|
||||
return createConfigMapMirror(src, targetNamespace, sourceHash)
|
||||
default:
|
||||
// For unstructured/CRD resources
|
||||
return createUnstructuredMirror(source, targetNamespace, sourceHash)
|
||||
}
|
||||
}
|
||||
|
||||
// createSecretMirror creates a mirror of a Secret.
|
||||
func createSecretMirror(source *corev1.Secret, targetNamespace, sourceHash string) (*corev1.Secret, error) {
|
||||
mirror := &corev1.Secret{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: source.Name,
|
||||
Namespace: targetNamespace,
|
||||
Labels: map[string]string{
|
||||
constants.LabelManagedBy: constants.ControllerName,
|
||||
constants.LabelMirror: "true",
|
||||
},
|
||||
Annotations: buildMirrorAnnotations(source, sourceHash),
|
||||
},
|
||||
Type: source.Type,
|
||||
Data: source.Data,
|
||||
// Note: Don't copy StringData as it's write-only and gets converted to Data
|
||||
}
|
||||
|
||||
return mirror, nil
|
||||
}
|
||||
|
||||
// createConfigMapMirror creates a mirror of a ConfigMap.
|
||||
func createConfigMapMirror(source *corev1.ConfigMap, targetNamespace, sourceHash string) (*corev1.ConfigMap, error) {
|
||||
mirror := &corev1.ConfigMap{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: source.Name,
|
||||
Namespace: targetNamespace,
|
||||
Labels: map[string]string{
|
||||
constants.LabelManagedBy: constants.ControllerName,
|
||||
constants.LabelMirror: "true",
|
||||
},
|
||||
Annotations: buildMirrorAnnotations(source, sourceHash),
|
||||
},
|
||||
Data: source.Data,
|
||||
BinaryData: source.BinaryData,
|
||||
}
|
||||
|
||||
return mirror, nil
|
||||
}
|
||||
|
||||
// filterKubeMirrorMetadata removes all kubemirror.raczylo.com/* keys from metadata.
|
||||
// This prevents source kubemirror labels/annotations from being copied to mirrors.
|
||||
func filterKubeMirrorMetadata(metadata map[string]string) map[string]string {
|
||||
filtered := make(map[string]string)
|
||||
for k, v := range metadata {
|
||||
// Skip all kubemirror.raczylo.com keys
|
||||
if !strings.HasPrefix(k, "kubemirror.raczylo.com/") {
|
||||
filtered[k] = v
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
// createUnstructuredMirror creates a mirror of an unstructured resource (CRD).
|
||||
func createUnstructuredMirror(source runtime.Object, targetNamespace, sourceHash string) (*unstructured.Unstructured, error) {
|
||||
// Convert to unstructured
|
||||
unstructuredObj, err := runtime.DefaultUnstructuredConverter.ToUnstructured(source)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to convert to unstructured: %w", err)
|
||||
}
|
||||
|
||||
u := &unstructured.Unstructured{Object: unstructuredObj}
|
||||
|
||||
// Create mirror
|
||||
mirror := u.DeepCopy()
|
||||
mirror.SetNamespace(targetNamespace)
|
||||
|
||||
// Remove kubemirror labels from source (don't propagate to mirrors)
|
||||
labels := mirror.GetLabels()
|
||||
if labels == nil {
|
||||
labels = make(map[string]string)
|
||||
}
|
||||
labels = filterKubeMirrorMetadata(labels)
|
||||
labels[constants.LabelManagedBy] = constants.ControllerName
|
||||
labels[constants.LabelMirror] = "true"
|
||||
mirror.SetLabels(labels)
|
||||
|
||||
// Remove kubemirror annotations from source (don't propagate to mirrors)
|
||||
existingAnnotations := mirror.GetAnnotations()
|
||||
if existingAnnotations == nil {
|
||||
existingAnnotations = make(map[string]string)
|
||||
}
|
||||
existingAnnotations = filterKubeMirrorMetadata(existingAnnotations)
|
||||
|
||||
// Add mirror-specific annotations
|
||||
annotations := buildMirrorAnnotations(source, sourceHash)
|
||||
for k, v := range annotations {
|
||||
existingAnnotations[k] = v
|
||||
}
|
||||
mirror.SetAnnotations(existingAnnotations)
|
||||
|
||||
// Remove status (never mirror status)
|
||||
unstructured.RemoveNestedField(mirror.Object, "status")
|
||||
|
||||
// Clear resource-specific metadata
|
||||
mirror.SetResourceVersion("")
|
||||
mirror.SetUID("")
|
||||
mirror.SetGeneration(0)
|
||||
mirror.SetCreationTimestamp(metav1.Time{})
|
||||
mirror.SetFinalizers(nil) // Mirrors should not have finalizers
|
||||
|
||||
return mirror, nil
|
||||
}
|
||||
|
||||
// buildMirrorAnnotations builds the ownership annotations for a mirror resource.
|
||||
func buildMirrorAnnotations(source runtime.Object, sourceHash string) map[string]string {
|
||||
sourceObj, _ := source.(metav1.Object)
|
||||
|
||||
annotations := map[string]string{
|
||||
constants.AnnotationSourceNamespace: sourceObj.GetNamespace(),
|
||||
constants.AnnotationSourceName: sourceObj.GetName(),
|
||||
constants.AnnotationSourceUID: string(sourceObj.GetUID()),
|
||||
constants.AnnotationSourceContentHash: sourceHash,
|
||||
constants.AnnotationLastSyncTime: time.Now().UTC().Format(time.RFC3339),
|
||||
}
|
||||
|
||||
// Add generation if available
|
||||
if sourceObj.GetGeneration() > 0 {
|
||||
annotations[constants.AnnotationSourceGeneration] = fmt.Sprintf("%d", sourceObj.GetGeneration())
|
||||
}
|
||||
|
||||
// Add resource version for debugging
|
||||
if sourceObj.GetResourceVersion() != "" {
|
||||
annotations[constants.AnnotationSourceResourceVersion] = sourceObj.GetResourceVersion()
|
||||
}
|
||||
|
||||
return annotations
|
||||
}
|
||||
|
||||
// UpdateMirror updates an existing mirror with new source content.
|
||||
func UpdateMirror(mirror, source runtime.Object) error {
|
||||
// Compute new source hash
|
||||
sourceHash, err := hash.ComputeContentHash(source)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to compute source hash: %w", err)
|
||||
}
|
||||
|
||||
// Update based on type
|
||||
switch m := mirror.(type) {
|
||||
case *corev1.Secret:
|
||||
src := source.(*corev1.Secret)
|
||||
m.Data = src.Data
|
||||
m.Type = src.Type
|
||||
updateMirrorAnnotations(m, source, sourceHash)
|
||||
case *corev1.ConfigMap:
|
||||
src := source.(*corev1.ConfigMap)
|
||||
m.Data = src.Data
|
||||
m.BinaryData = src.BinaryData
|
||||
updateMirrorAnnotations(m, source, sourceHash)
|
||||
default:
|
||||
// Unstructured
|
||||
return updateUnstructuredMirror(mirror, source, sourceHash)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// updateMirrorAnnotations updates the ownership annotations on a mirror.
|
||||
func updateMirrorAnnotations(mirror metav1.Object, source runtime.Object, sourceHash string) {
|
||||
sourceObj, _ := source.(metav1.Object)
|
||||
|
||||
annotations := mirror.GetAnnotations()
|
||||
if annotations == nil {
|
||||
annotations = make(map[string]string)
|
||||
}
|
||||
|
||||
annotations[constants.AnnotationSourceContentHash] = sourceHash
|
||||
annotations[constants.AnnotationLastSyncTime] = time.Now().UTC().Format(time.RFC3339)
|
||||
|
||||
if sourceObj.GetGeneration() > 0 {
|
||||
annotations[constants.AnnotationSourceGeneration] = fmt.Sprintf("%d", sourceObj.GetGeneration())
|
||||
}
|
||||
|
||||
if sourceObj.GetResourceVersion() != "" {
|
||||
annotations[constants.AnnotationSourceResourceVersion] = sourceObj.GetResourceVersion()
|
||||
}
|
||||
|
||||
mirror.SetAnnotations(annotations)
|
||||
}
|
||||
|
||||
// updateUnstructuredMirror updates an unstructured mirror.
|
||||
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)
|
||||
}
|
||||
if found {
|
||||
if err := unstructured.SetNestedMap(m.Object, sourceSpec, "spec"); err != nil {
|
||||
return fmt.Errorf("failed to set mirror spec: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Update annotations
|
||||
updateMirrorAnnotations(m, source, sourceHash)
|
||||
|
||||
// Ensure mirrors never have finalizers (even if they were added before this fix)
|
||||
m.SetFinalizers(nil)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsManagedByUs checks if a resource is managed by kubemirror.
|
||||
func IsManagedByUs(obj metav1.Object) bool {
|
||||
labels := obj.GetLabels()
|
||||
if labels == nil {
|
||||
return false
|
||||
}
|
||||
return labels[constants.LabelManagedBy] == constants.ControllerName
|
||||
}
|
||||
|
||||
// IsMirrorResource checks if a resource is a mirror (not a source).
|
||||
func IsMirrorResource(obj metav1.Object) bool {
|
||||
labels := obj.GetLabels()
|
||||
if labels == nil {
|
||||
return false
|
||||
}
|
||||
return labels[constants.LabelMirror] == "true"
|
||||
}
|
||||
|
||||
// GetSourceReference extracts the source reference from a mirror's annotations.
|
||||
func GetSourceReference(mirror metav1.Object) (namespace, name, uid string, found bool) {
|
||||
annotations := mirror.GetAnnotations()
|
||||
if annotations == nil {
|
||||
return "", "", "", false
|
||||
}
|
||||
|
||||
namespace = annotations[constants.AnnotationSourceNamespace]
|
||||
name = annotations[constants.AnnotationSourceName]
|
||||
uid = annotations[constants.AnnotationSourceUID]
|
||||
|
||||
if namespace == "" || name == "" {
|
||||
return "", "", "", false
|
||||
}
|
||||
|
||||
return namespace, name, uid, true
|
||||
}
|
||||
@@ -0,0 +1,622 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||
|
||||
"github.com/lukaszraczylo/kubemirror/pkg/constants"
|
||||
)
|
||||
|
||||
func TestCreateMirror_Secret(t *testing.T) {
|
||||
source := &corev1.Secret{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-secret",
|
||||
Namespace: "default",
|
||||
UID: "source-uid-123",
|
||||
ResourceVersion: "100",
|
||||
Generation: 5,
|
||||
},
|
||||
Type: corev1.SecretTypeOpaque,
|
||||
Data: map[string][]byte{
|
||||
"password": []byte("secret123"),
|
||||
},
|
||||
}
|
||||
|
||||
mirror, err := CreateMirror(source, "app1")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, mirror)
|
||||
|
||||
secretMirror, ok := mirror.(*corev1.Secret)
|
||||
require.True(t, ok, "mirror should be a Secret")
|
||||
|
||||
// Verify mirror properties
|
||||
assert.Equal(t, "test-secret", secretMirror.Name)
|
||||
assert.Equal(t, "app1", secretMirror.Namespace)
|
||||
assert.Equal(t, corev1.SecretTypeOpaque, secretMirror.Type)
|
||||
assert.Equal(t, source.Data, secretMirror.Data)
|
||||
|
||||
// Verify ownership labels
|
||||
assert.Equal(t, constants.ControllerName, secretMirror.Labels[constants.LabelManagedBy])
|
||||
assert.Equal(t, "true", secretMirror.Labels[constants.LabelMirror])
|
||||
|
||||
// Verify ownership annotations
|
||||
assert.Equal(t, "default", secretMirror.Annotations[constants.AnnotationSourceNamespace])
|
||||
assert.Equal(t, "test-secret", secretMirror.Annotations[constants.AnnotationSourceName])
|
||||
assert.Equal(t, "source-uid-123", secretMirror.Annotations[constants.AnnotationSourceUID])
|
||||
assert.Equal(t, "5", secretMirror.Annotations[constants.AnnotationSourceGeneration])
|
||||
assert.NotEmpty(t, secretMirror.Annotations[constants.AnnotationSourceContentHash])
|
||||
assert.NotEmpty(t, secretMirror.Annotations[constants.AnnotationLastSyncTime])
|
||||
}
|
||||
|
||||
func TestCreateMirror_ConfigMap(t *testing.T) {
|
||||
source := &corev1.ConfigMap{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-config",
|
||||
Namespace: "default",
|
||||
UID: "config-uid-456",
|
||||
ResourceVersion: "200",
|
||||
},
|
||||
Data: map[string]string{
|
||||
"config.yaml": "setting: value",
|
||||
},
|
||||
BinaryData: map[string][]byte{
|
||||
"binary": {0x00, 0x01, 0x02},
|
||||
},
|
||||
}
|
||||
|
||||
mirror, err := CreateMirror(source, "prod-ns")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, mirror)
|
||||
|
||||
cmMirror, ok := mirror.(*corev1.ConfigMap)
|
||||
require.True(t, ok, "mirror should be a ConfigMap")
|
||||
|
||||
// Verify mirror properties
|
||||
assert.Equal(t, "test-config", cmMirror.Name)
|
||||
assert.Equal(t, "prod-ns", cmMirror.Namespace)
|
||||
assert.Equal(t, source.Data, cmMirror.Data)
|
||||
assert.Equal(t, source.BinaryData, cmMirror.BinaryData)
|
||||
|
||||
// Verify ownership labels
|
||||
assert.Equal(t, constants.ControllerName, cmMirror.Labels[constants.LabelManagedBy])
|
||||
assert.Equal(t, "true", cmMirror.Labels[constants.LabelMirror])
|
||||
|
||||
// Verify ownership annotations
|
||||
assert.Equal(t, "default", cmMirror.Annotations[constants.AnnotationSourceNamespace])
|
||||
assert.Equal(t, "test-config", cmMirror.Annotations[constants.AnnotationSourceName])
|
||||
assert.Equal(t, "config-uid-456", cmMirror.Annotations[constants.AnnotationSourceUID])
|
||||
assert.NotEmpty(t, cmMirror.Annotations[constants.AnnotationSourceContentHash])
|
||||
}
|
||||
|
||||
func TestCreateMirror_Unstructured(t *testing.T) {
|
||||
source := &unstructured.Unstructured{
|
||||
Object: map[string]interface{}{
|
||||
"apiVersion": "traefik.io/v1alpha1",
|
||||
"kind": "Middleware",
|
||||
"metadata": map[string]interface{}{
|
||||
"name": "test-middleware",
|
||||
"namespace": "traefik",
|
||||
"uid": "middleware-uid-789",
|
||||
"resourceVersion": "300",
|
||||
"generation": int64(3),
|
||||
},
|
||||
"spec": map[string]interface{}{
|
||||
"basicAuth": map[string]interface{}{
|
||||
"secret": "auth-secret",
|
||||
},
|
||||
},
|
||||
"status": map[string]interface{}{
|
||||
"condition": "Ready",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
mirror, err := CreateMirror(source, "app-ns")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, mirror)
|
||||
|
||||
uMirror, ok := mirror.(*unstructured.Unstructured)
|
||||
require.True(t, ok, "mirror should be Unstructured")
|
||||
|
||||
// Verify mirror properties
|
||||
assert.Equal(t, "test-middleware", uMirror.GetName())
|
||||
assert.Equal(t, "app-ns", uMirror.GetNamespace())
|
||||
|
||||
// Verify spec is copied
|
||||
spec, found, err := unstructured.NestedMap(uMirror.Object, "spec")
|
||||
require.NoError(t, err)
|
||||
require.True(t, found)
|
||||
assert.NotNil(t, spec)
|
||||
|
||||
// Verify status is NOT copied
|
||||
_, found, err = unstructured.NestedMap(uMirror.Object, "status")
|
||||
require.NoError(t, err)
|
||||
assert.False(t, found, "status should not be mirrored")
|
||||
|
||||
// Verify metadata is cleared
|
||||
assert.Empty(t, uMirror.GetResourceVersion())
|
||||
assert.Empty(t, uMirror.GetUID())
|
||||
assert.Equal(t, int64(0), uMirror.GetGeneration())
|
||||
|
||||
// Verify ownership labels
|
||||
assert.Equal(t, constants.ControllerName, uMirror.GetLabels()[constants.LabelManagedBy])
|
||||
assert.Equal(t, "true", uMirror.GetLabels()[constants.LabelMirror])
|
||||
|
||||
// Verify ownership annotations
|
||||
annotations := uMirror.GetAnnotations()
|
||||
assert.Equal(t, "traefik", annotations[constants.AnnotationSourceNamespace])
|
||||
assert.Equal(t, "test-middleware", annotations[constants.AnnotationSourceName])
|
||||
assert.Equal(t, "middleware-uid-789", annotations[constants.AnnotationSourceUID])
|
||||
assert.Equal(t, "3", annotations[constants.AnnotationSourceGeneration])
|
||||
}
|
||||
|
||||
func TestUpdateMirror_Secret(t *testing.T) {
|
||||
mirror := &corev1.Secret{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-secret",
|
||||
Namespace: "app1",
|
||||
Labels: map[string]string{
|
||||
constants.LabelManagedBy: constants.ControllerName,
|
||||
},
|
||||
Annotations: map[string]string{
|
||||
constants.AnnotationSourceContentHash: "oldhash",
|
||||
},
|
||||
},
|
||||
Data: map[string][]byte{
|
||||
"password": []byte("old"),
|
||||
},
|
||||
Type: corev1.SecretTypeOpaque,
|
||||
}
|
||||
|
||||
source := &corev1.Secret{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-secret",
|
||||
Namespace: "default",
|
||||
Generation: 10,
|
||||
},
|
||||
Data: map[string][]byte{
|
||||
"password": []byte("new"),
|
||||
},
|
||||
Type: corev1.SecretTypeTLS,
|
||||
}
|
||||
|
||||
err := UpdateMirror(mirror, source)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify data updated
|
||||
assert.Equal(t, source.Data, mirror.Data)
|
||||
assert.Equal(t, source.Type, mirror.Type)
|
||||
|
||||
// Verify hash updated
|
||||
assert.NotEqual(t, "oldhash", mirror.Annotations[constants.AnnotationSourceContentHash])
|
||||
assert.Equal(t, "10", mirror.Annotations[constants.AnnotationSourceGeneration])
|
||||
assert.NotEmpty(t, mirror.Annotations[constants.AnnotationLastSyncTime])
|
||||
}
|
||||
|
||||
func TestUpdateMirror_ConfigMap(t *testing.T) {
|
||||
mirror := &corev1.ConfigMap{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-config",
|
||||
Namespace: "app1",
|
||||
Annotations: map[string]string{
|
||||
constants.AnnotationSourceContentHash: "oldhash",
|
||||
},
|
||||
},
|
||||
Data: map[string]string{
|
||||
"key": "old",
|
||||
},
|
||||
}
|
||||
|
||||
source := &corev1.ConfigMap{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-config",
|
||||
Namespace: "default",
|
||||
},
|
||||
Data: map[string]string{
|
||||
"key": "new",
|
||||
},
|
||||
BinaryData: map[string][]byte{
|
||||
"binary": {0xFF},
|
||||
},
|
||||
}
|
||||
|
||||
err := UpdateMirror(mirror, source)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify data updated
|
||||
assert.Equal(t, source.Data, mirror.Data)
|
||||
assert.Equal(t, source.BinaryData, mirror.BinaryData)
|
||||
assert.NotEqual(t, "oldhash", mirror.Annotations[constants.AnnotationSourceContentHash])
|
||||
}
|
||||
|
||||
func TestIsManagedByUs(t *testing.T) {
|
||||
tests := []struct {
|
||||
obj metav1.Object
|
||||
name string
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "managed by us",
|
||||
obj: &corev1.Secret{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Labels: map[string]string{
|
||||
constants.LabelManagedBy: constants.ControllerName,
|
||||
},
|
||||
},
|
||||
},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "not managed by us",
|
||||
obj: &corev1.Secret{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Labels: map[string]string{
|
||||
constants.LabelManagedBy: "other-controller",
|
||||
},
|
||||
},
|
||||
},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "no labels",
|
||||
obj: &corev1.Secret{
|
||||
ObjectMeta: metav1.ObjectMeta{},
|
||||
},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "nil labels",
|
||||
obj: &corev1.Secret{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Labels: nil,
|
||||
},
|
||||
},
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := IsManagedByUs(tt.obj)
|
||||
assert.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsMirrorResource(t *testing.T) {
|
||||
tests := []struct {
|
||||
obj metav1.Object
|
||||
name string
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "is mirror",
|
||||
obj: &corev1.Secret{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Labels: map[string]string{
|
||||
constants.LabelMirror: "true",
|
||||
},
|
||||
},
|
||||
},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "not mirror",
|
||||
obj: &corev1.Secret{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Labels: map[string]string{
|
||||
constants.LabelMirror: "false",
|
||||
},
|
||||
},
|
||||
},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "no labels",
|
||||
obj: &corev1.Secret{
|
||||
ObjectMeta: metav1.ObjectMeta{},
|
||||
},
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := IsMirrorResource(tt.obj)
|
||||
assert.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetSourceReference(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
obj metav1.Object
|
||||
wantNamespace string
|
||||
wantName string
|
||||
wantUID string
|
||||
wantFound bool
|
||||
}{
|
||||
{
|
||||
name: "valid source reference",
|
||||
obj: &corev1.Secret{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Annotations: map[string]string{
|
||||
constants.AnnotationSourceNamespace: "default",
|
||||
constants.AnnotationSourceName: "my-secret",
|
||||
constants.AnnotationSourceUID: "uid-123",
|
||||
},
|
||||
},
|
||||
},
|
||||
wantNamespace: "default",
|
||||
wantName: "my-secret",
|
||||
wantUID: "uid-123",
|
||||
wantFound: true,
|
||||
},
|
||||
{
|
||||
name: "missing annotations",
|
||||
obj: &corev1.Secret{
|
||||
ObjectMeta: metav1.ObjectMeta{},
|
||||
},
|
||||
wantFound: false,
|
||||
},
|
||||
{
|
||||
name: "incomplete annotations - missing name",
|
||||
obj: &corev1.Secret{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Annotations: map[string]string{
|
||||
constants.AnnotationSourceNamespace: "default",
|
||||
},
|
||||
},
|
||||
},
|
||||
wantFound: false,
|
||||
},
|
||||
{
|
||||
name: "incomplete annotations - missing namespace",
|
||||
obj: &corev1.Secret{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Annotations: map[string]string{
|
||||
constants.AnnotationSourceName: "my-secret",
|
||||
},
|
||||
},
|
||||
},
|
||||
wantFound: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
gotNS, gotName, gotUID, gotFound := GetSourceReference(tt.obj)
|
||||
assert.Equal(t, tt.wantFound, gotFound)
|
||||
if tt.wantFound {
|
||||
assert.Equal(t, tt.wantNamespace, gotNS)
|
||||
assert.Equal(t, tt.wantName, gotName)
|
||||
assert.Equal(t, tt.wantUID, gotUID)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Test that mirrors don't include sync annotations (prevent infinite loop)
|
||||
func TestCreateMirror_NoSyncAnnotations(t *testing.T) {
|
||||
source := &corev1.Secret{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test",
|
||||
Namespace: "default",
|
||||
Labels: map[string]string{
|
||||
constants.LabelEnabled: "true",
|
||||
},
|
||||
Annotations: map[string]string{
|
||||
constants.AnnotationSync: "true",
|
||||
constants.AnnotationTargetNamespaces: "app1,app2",
|
||||
constants.AnnotationExclude: "false",
|
||||
constants.AnnotationRecreateOnImmutableChange: "true",
|
||||
},
|
||||
},
|
||||
Data: map[string][]byte{"key": []byte("value")},
|
||||
}
|
||||
|
||||
mirror, err := CreateMirror(source, "app1")
|
||||
require.NoError(t, err)
|
||||
|
||||
secretMirror := mirror.(*corev1.Secret)
|
||||
|
||||
// Verify sync annotations are NOT copied
|
||||
assert.NotContains(t, secretMirror.Annotations, constants.AnnotationSync)
|
||||
assert.NotContains(t, secretMirror.Annotations, constants.AnnotationTargetNamespaces)
|
||||
|
||||
// Verify enabled label is NOT copied
|
||||
assert.NotContains(t, secretMirror.Labels, constants.LabelEnabled)
|
||||
|
||||
// Verify ownership annotations ARE present
|
||||
assert.Contains(t, secretMirror.Annotations, constants.AnnotationSourceNamespace)
|
||||
}
|
||||
|
||||
// Benchmarks for critical paths
|
||||
|
||||
func BenchmarkCreateMirror_Secret(b *testing.B) {
|
||||
source := &corev1.Secret{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "bench-secret",
|
||||
Namespace: "default",
|
||||
UID: "uid-123",
|
||||
ResourceVersion: "100",
|
||||
Generation: 1,
|
||||
},
|
||||
Type: corev1.SecretTypeOpaque,
|
||||
Data: map[string][]byte{
|
||||
"password": []byte("secret123"),
|
||||
"username": []byte("admin"),
|
||||
"token": []byte("abcdef123456"),
|
||||
},
|
||||
}
|
||||
|
||||
b.ResetTimer()
|
||||
b.ReportAllocs()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_, _ = CreateMirror(source, "target-ns")
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkCreateMirror_ConfigMap(b *testing.B) {
|
||||
source := &corev1.ConfigMap{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "bench-config",
|
||||
Namespace: "default",
|
||||
UID: "uid-456",
|
||||
ResourceVersion: "200",
|
||||
},
|
||||
Data: map[string]string{
|
||||
"config.yaml": "key1: value1\nkey2: value2\nkey3: value3",
|
||||
"app.conf": "setting=value",
|
||||
},
|
||||
BinaryData: map[string][]byte{
|
||||
"binary": {0x00, 0x01, 0x02, 0x03, 0x04},
|
||||
},
|
||||
}
|
||||
|
||||
b.ResetTimer()
|
||||
b.ReportAllocs()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_, _ = CreateMirror(source, "target-ns")
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkCreateMirror_Unstructured(b *testing.B) {
|
||||
source := &unstructured.Unstructured{
|
||||
Object: map[string]interface{}{
|
||||
"apiVersion": "traefik.io/v1alpha1",
|
||||
"kind": "Middleware",
|
||||
"metadata": map[string]interface{}{
|
||||
"name": "bench-middleware",
|
||||
"namespace": "traefik",
|
||||
"uid": "uid-789",
|
||||
"resourceVersion": "300",
|
||||
"generation": int64(3),
|
||||
},
|
||||
"spec": map[string]interface{}{
|
||||
"basicAuth": map[string]interface{}{
|
||||
"secret": "auth-secret",
|
||||
},
|
||||
"headers": map[string]interface{}{
|
||||
"customRequestHeaders": map[string]interface{}{
|
||||
"X-Custom-Header": "value",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
b.ResetTimer()
|
||||
b.ReportAllocs()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_, _ = CreateMirror(source, "target-ns")
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkUpdateMirror_Secret(b *testing.B) {
|
||||
mirror := &corev1.Secret{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-secret",
|
||||
Namespace: "app1",
|
||||
Labels: map[string]string{
|
||||
constants.LabelManagedBy: constants.ControllerName,
|
||||
},
|
||||
Annotations: map[string]string{
|
||||
constants.AnnotationSourceContentHash: "oldhash",
|
||||
},
|
||||
},
|
||||
Data: map[string][]byte{
|
||||
"password": []byte("old"),
|
||||
},
|
||||
Type: corev1.SecretTypeOpaque,
|
||||
}
|
||||
|
||||
source := &corev1.Secret{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-secret",
|
||||
Namespace: "default",
|
||||
Generation: 10,
|
||||
},
|
||||
Data: map[string][]byte{
|
||||
"password": []byte("new"),
|
||||
},
|
||||
Type: corev1.SecretTypeOpaque,
|
||||
}
|
||||
|
||||
b.ResetTimer()
|
||||
b.ReportAllocs()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = UpdateMirror(mirror, source)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkUpdateMirror_ConfigMap(b *testing.B) {
|
||||
mirror := &corev1.ConfigMap{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-config",
|
||||
Namespace: "app1",
|
||||
Annotations: map[string]string{
|
||||
constants.AnnotationSourceContentHash: "oldhash",
|
||||
},
|
||||
},
|
||||
Data: map[string]string{
|
||||
"key": "old",
|
||||
},
|
||||
}
|
||||
|
||||
source := &corev1.ConfigMap{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-config",
|
||||
Namespace: "default",
|
||||
},
|
||||
Data: map[string]string{
|
||||
"key": "new",
|
||||
},
|
||||
}
|
||||
|
||||
b.ResetTimer()
|
||||
b.ReportAllocs()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = UpdateMirror(mirror, source)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkIsManagedByUs(b *testing.B) {
|
||||
obj := &corev1.Secret{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Labels: map[string]string{
|
||||
constants.LabelManagedBy: constants.ControllerName,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
b.ResetTimer()
|
||||
b.ReportAllocs()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = IsManagedByUs(obj)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkGetSourceReference(b *testing.B) {
|
||||
obj := &corev1.Secret{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Annotations: map[string]string{
|
||||
constants.AnnotationSourceNamespace: "default",
|
||||
constants.AnnotationSourceName: "my-secret",
|
||||
constants.AnnotationSourceUID: "uid-123",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
b.ResetTimer()
|
||||
b.ReportAllocs()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_, _, _, _ = GetSourceReference(obj)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
// Package controller implements the kubemirror reconciliation logic.
|
||||
package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||
|
||||
"github.com/lukaszraczylo/kubemirror/pkg/constants"
|
||||
)
|
||||
|
||||
// KubernetesNamespaceLister implements NamespaceLister using the Kubernetes API.
|
||||
type KubernetesNamespaceLister struct {
|
||||
client client.Client
|
||||
}
|
||||
|
||||
// NewKubernetesNamespaceLister creates a new KubernetesNamespaceLister.
|
||||
func NewKubernetesNamespaceLister(client client.Client) *KubernetesNamespaceLister {
|
||||
return &KubernetesNamespaceLister{
|
||||
client: client,
|
||||
}
|
||||
}
|
||||
|
||||
// ListNamespaces returns all namespace names in the cluster.
|
||||
func (k *KubernetesNamespaceLister) ListNamespaces(ctx context.Context) ([]string, error) {
|
||||
namespaceList := &corev1.NamespaceList{}
|
||||
if err := k.client.List(ctx, namespaceList); 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
|
||||
}
|
||||
|
||||
// ListAllowMirrorsNamespaces returns namespaces that have the allow-mirrors label.
|
||||
func (k *KubernetesNamespaceLister) ListAllowMirrorsNamespaces(ctx context.Context) ([]string, error) {
|
||||
namespaceList := &corev1.NamespaceList{}
|
||||
|
||||
// List namespaces with the allow-mirrors label
|
||||
if err := k.client.List(ctx, namespaceList, client.MatchingLabels{
|
||||
constants.LabelAllowMirrors: "true",
|
||||
}); 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,446 @@
|
||||
// Package controller implements the kubemirror reconciliation logic.
|
||||
package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
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"
|
||||
"k8s.io/apimachinery/pkg/types"
|
||||
ctrl "sigs.k8s.io/controller-runtime"
|
||||
"sigs.k8s.io/controller-runtime/pkg/builder"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||
"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
|
||||
"sigs.k8s.io/controller-runtime/pkg/event"
|
||||
"sigs.k8s.io/controller-runtime/pkg/handler"
|
||||
"sigs.k8s.io/controller-runtime/pkg/log"
|
||||
"sigs.k8s.io/controller-runtime/pkg/predicate"
|
||||
"sigs.k8s.io/controller-runtime/pkg/reconcile"
|
||||
|
||||
"github.com/lukaszraczylo/kubemirror/pkg/config"
|
||||
"github.com/lukaszraczylo/kubemirror/pkg/constants"
|
||||
"github.com/lukaszraczylo/kubemirror/pkg/filter"
|
||||
"github.com/lukaszraczylo/kubemirror/pkg/hash"
|
||||
)
|
||||
|
||||
// SourceReconciler reconciles source resources that need mirroring.
|
||||
type SourceReconciler struct {
|
||||
client.Client
|
||||
Scheme *runtime.Scheme
|
||||
Config *config.Config
|
||||
Filter *filter.NamespaceFilter
|
||||
NamespaceLister NamespaceLister
|
||||
GVK schema.GroupVersionKind // The resource type this reconciler handles
|
||||
}
|
||||
|
||||
// NamespaceLister provides a list of all namespaces in the cluster.
|
||||
// This interface allows for testing with mocks.
|
||||
type NamespaceLister interface {
|
||||
ListNamespaces(ctx context.Context) ([]string, error)
|
||||
ListAllowMirrorsNamespaces(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
|
||||
|
||||
// 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 {
|
||||
if errors.IsNotFound(err) {
|
||||
// Resource deleted - nothing to do
|
||||
return ctrl.Result{}, nil
|
||||
}
|
||||
logger.Error(err, "failed to get resource")
|
||||
return ctrl.Result{}, err
|
||||
}
|
||||
|
||||
sourceObj := source
|
||||
|
||||
// Check if this is a mirror resource (shouldn't reconcile mirrors as sources)
|
||||
if IsMirrorResource(sourceObj) {
|
||||
// Silently skip - mirrors reconcile via watch, not as sources
|
||||
return ctrl.Result{}, nil
|
||||
}
|
||||
|
||||
// Check if resource is enabled for mirroring
|
||||
if !isEnabledForMirroring(sourceObj) {
|
||||
// Silently skip - don't log as it would be too noisy
|
||||
return r.handleDisabled(ctx, sourceObj)
|
||||
}
|
||||
|
||||
// Handle deletion
|
||||
if !sourceObj.GetDeletionTimestamp().IsZero() {
|
||||
return r.handleDeletion(ctx, source, sourceObj)
|
||||
}
|
||||
|
||||
// Add finalizer if not present
|
||||
// source (*unstructured.Unstructured) already implements client.Object
|
||||
if !controllerutil.ContainsFinalizer(source, constants.FinalizerName) {
|
||||
controllerutil.AddFinalizer(source, constants.FinalizerName)
|
||||
if err := r.Update(ctx, source); err != nil {
|
||||
logger.Error(err, "failed to add finalizer")
|
||||
return ctrl.Result{}, err
|
||||
}
|
||||
logger.V(1).Info("added finalizer")
|
||||
}
|
||||
|
||||
// Get target namespaces
|
||||
targetNamespaces, err := r.resolveTargetNamespaces(ctx, sourceObj)
|
||||
if err != nil {
|
||||
logger.Error(err, "failed to resolve target namespaces")
|
||||
return ctrl.Result{}, err
|
||||
}
|
||||
|
||||
if len(targetNamespaces) == 0 {
|
||||
logger.V(1).Info("no target namespaces resolved")
|
||||
return ctrl.Result{}, nil
|
||||
}
|
||||
|
||||
logger.V(1).Info("reconciling mirrors", "targetCount", len(targetNamespaces))
|
||||
|
||||
// Reconcile each target namespace
|
||||
var reconciledCount, errorCount int
|
||||
for _, targetNs := range targetNamespaces {
|
||||
if err := r.reconcileMirror(ctx, source, sourceObj, targetNs); err != nil {
|
||||
logger.Error(err, "failed to reconcile mirror", "targetNamespace", targetNs)
|
||||
errorCount++
|
||||
} else {
|
||||
reconciledCount++
|
||||
}
|
||||
}
|
||||
|
||||
// Update status annotation with last sync info
|
||||
if err := r.updateLastSyncStatus(ctx, source, sourceObj, reconciledCount, errorCount); err != nil {
|
||||
logger.Error(err, "failed to update sync status")
|
||||
return ctrl.Result{}, err
|
||||
}
|
||||
|
||||
logger.Info("reconciliation complete",
|
||||
"reconciled", reconciledCount,
|
||||
"errors", errorCount,
|
||||
"total", len(targetNamespaces))
|
||||
|
||||
// Requeue if there were errors
|
||||
if errorCount > 0 {
|
||||
return ctrl.Result{Requeue: true}, fmt.Errorf("failed to reconcile %d/%d mirrors", errorCount, len(targetNamespaces))
|
||||
}
|
||||
|
||||
return ctrl.Result{}, nil
|
||||
}
|
||||
|
||||
// handleDeletion removes finalizer after cleaning up all mirrors.
|
||||
func (r *SourceReconciler) handleDeletion(ctx context.Context, source runtime.Object, sourceObj metav1.Object) (ctrl.Result, error) {
|
||||
logger := log.FromContext(ctx)
|
||||
|
||||
// source (*unstructured.Unstructured) already implements client.Object
|
||||
sourceUnstructured := source.(*unstructured.Unstructured)
|
||||
if !controllerutil.ContainsFinalizer(sourceUnstructured, constants.FinalizerName) {
|
||||
return ctrl.Result{}, nil
|
||||
}
|
||||
|
||||
// Delete all mirrors
|
||||
if err := r.deleteAllMirrors(ctx, sourceObj); err != nil {
|
||||
logger.Error(err, "failed to delete mirrors")
|
||||
return ctrl.Result{}, err
|
||||
}
|
||||
|
||||
// Remove finalizer
|
||||
controllerutil.RemoveFinalizer(sourceUnstructured, constants.FinalizerName)
|
||||
if err := r.Update(ctx, sourceUnstructured); err != nil {
|
||||
logger.Error(err, "failed to remove finalizer")
|
||||
return ctrl.Result{}, err
|
||||
}
|
||||
|
||||
logger.Info("finalizer removed, mirrors deleted")
|
||||
return ctrl.Result{}, nil
|
||||
}
|
||||
|
||||
// handleDisabled removes mirrors when a resource is disabled.
|
||||
func (r *SourceReconciler) handleDisabled(ctx context.Context, sourceObj metav1.Object) (ctrl.Result, error) {
|
||||
logger := log.FromContext(ctx)
|
||||
|
||||
// Source is already a client.Object (unstructured implements it)
|
||||
sourceClient := sourceObj.(client.Object)
|
||||
|
||||
// If resource has finalizer, clean up mirrors and remove it
|
||||
if controllerutil.ContainsFinalizer(sourceClient, constants.FinalizerName) {
|
||||
if err := r.deleteAllMirrors(ctx, sourceObj); err != nil {
|
||||
logger.Error(err, "failed to delete mirrors for disabled resource")
|
||||
return ctrl.Result{}, err
|
||||
}
|
||||
|
||||
// Remove finalizer
|
||||
controllerutil.RemoveFinalizer(sourceClient, constants.FinalizerName)
|
||||
if err := r.Update(ctx, sourceClient); err != nil {
|
||||
logger.Error(err, "failed to remove finalizer from disabled resource")
|
||||
return ctrl.Result{}, err
|
||||
}
|
||||
|
||||
logger.Info("mirrors deleted and finalizer removed for disabled resource")
|
||||
}
|
||||
|
||||
return ctrl.Result{}, nil
|
||||
}
|
||||
|
||||
// reconcileMirror creates or updates a mirror in the target namespace.
|
||||
func (r *SourceReconciler) reconcileMirror(ctx context.Context, source runtime.Object, sourceObj metav1.Object, targetNs string) error {
|
||||
logger := log.FromContext(ctx).WithValues("targetNamespace", targetNs)
|
||||
|
||||
// Try to get existing mirror as unstructured
|
||||
sourceUnstructured := source.(*unstructured.Unstructured)
|
||||
existing := &unstructured.Unstructured{}
|
||||
existing.SetGroupVersionKind(sourceUnstructured.GroupVersionKind())
|
||||
|
||||
err := r.Get(ctx, client.ObjectKey{Namespace: targetNs, Name: sourceObj.GetName()}, existing)
|
||||
if err != nil && !errors.IsNotFound(err) {
|
||||
return fmt.Errorf("failed to get existing mirror: %w", err)
|
||||
}
|
||||
|
||||
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")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check if update is needed
|
||||
needsSync, err := hash.NeedsSync(source, existing, existing.GetAnnotations())
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check if sync needed: %w", err)
|
||||
}
|
||||
|
||||
if !needsSync {
|
||||
logger.V(1).Info("mirror is up to date")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Update mirror
|
||||
if err := UpdateMirror(existing, source); err != nil {
|
||||
return fmt.Errorf("failed to update mirror: %w", err)
|
||||
}
|
||||
|
||||
if err := r.Update(ctx, existing); err != nil {
|
||||
return fmt.Errorf("failed to update mirror in cluster: %w", err)
|
||||
}
|
||||
|
||||
logger.Info("mirror updated")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Create new mirror
|
||||
mirror, err := CreateMirror(source, targetNs)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create mirror: %w", err)
|
||||
}
|
||||
|
||||
if err := r.Create(ctx, mirror.(client.Object)); err != nil {
|
||||
return fmt.Errorf("failed to create mirror in cluster: %w", err)
|
||||
}
|
||||
|
||||
logger.Info("mirror created")
|
||||
return nil
|
||||
}
|
||||
|
||||
// deleteAllMirrors deletes all mirrors for a source resource.
|
||||
func (r *SourceReconciler) deleteAllMirrors(ctx context.Context, sourceObj metav1.Object) error {
|
||||
logger := log.FromContext(ctx)
|
||||
|
||||
// List all namespaces
|
||||
allNamespaces, err := r.NamespaceLister.ListNamespaces(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to list namespaces: %w", err)
|
||||
}
|
||||
|
||||
// Get GVK from source object
|
||||
sourceUnstructured, ok := sourceObj.(*unstructured.Unstructured)
|
||||
if !ok {
|
||||
return fmt.Errorf("source object is not unstructured")
|
||||
}
|
||||
|
||||
var deleteCount int
|
||||
for _, ns := range allNamespaces {
|
||||
// Skip source namespace
|
||||
if ns == sourceObj.GetNamespace() {
|
||||
continue
|
||||
}
|
||||
|
||||
// Create mirror reference for deletion
|
||||
mirror := &unstructured.Unstructured{}
|
||||
mirror.SetGroupVersionKind(sourceUnstructured.GroupVersionKind())
|
||||
mirror.SetNamespace(ns)
|
||||
mirror.SetName(sourceObj.GetName())
|
||||
|
||||
err := r.Delete(ctx, mirror)
|
||||
if err == nil {
|
||||
deleteCount++
|
||||
} else if !errors.IsNotFound(err) {
|
||||
logger.Error(err, "failed to delete mirror", "namespace", ns)
|
||||
}
|
||||
}
|
||||
|
||||
logger.Info("deleted mirrors", "count", deleteCount)
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolveTargetNamespaces determines which namespaces should receive mirrors.
|
||||
func (r *SourceReconciler) resolveTargetNamespaces(ctx context.Context, sourceObj metav1.Object) ([]string, error) {
|
||||
annotations := sourceObj.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)
|
||||
}
|
||||
|
||||
// Resolve target namespaces
|
||||
targetNamespaces := filter.ResolveTargetNamespaces(
|
||||
patterns,
|
||||
allNamespaces,
|
||||
allowMirrorsNamespaces,
|
||||
sourceObj.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
|
||||
}
|
||||
|
||||
// updateLastSyncStatus updates the source resource's annotations with sync status.
|
||||
func (r *SourceReconciler) updateLastSyncStatus(ctx context.Context, source runtime.Object, sourceObj metav1.Object, reconciledCount, errorCount int) error {
|
||||
annotations := sourceObj.GetAnnotations()
|
||||
if annotations == nil {
|
||||
annotations = make(map[string]string)
|
||||
}
|
||||
|
||||
annotations[constants.AnnotationSyncStatus] = fmt.Sprintf("reconciled:%d,errors:%d", reconciledCount, errorCount)
|
||||
|
||||
sourceObj.SetAnnotations(annotations)
|
||||
// source (*unstructured.Unstructured) already implements client.Object
|
||||
return r.Update(ctx, source.(*unstructured.Unstructured))
|
||||
}
|
||||
|
||||
// isEnabledForMirroring checks if a resource has both the label and annotation for mirroring.
|
||||
func isEnabledForMirroring(obj metav1.Object) bool {
|
||||
// Check label
|
||||
labels := obj.GetLabels()
|
||||
if labels == nil || labels[constants.LabelEnabled] != "true" {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check annotation
|
||||
annotations := obj.GetAnnotations()
|
||||
if annotations == nil || annotations[constants.AnnotationSync] != "true" {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// SetupWithManager sets up the controller with the Manager.
|
||||
func (r *SourceReconciler) SetupWithManager(mgr ctrl.Manager) error {
|
||||
// Build predicate to only watch resources with enabled label
|
||||
// This reduces API server load by ~90%
|
||||
return ctrl.NewControllerManagedBy(mgr).
|
||||
For(&corev1.Secret{}).
|
||||
Complete(r)
|
||||
}
|
||||
|
||||
// SetupWithManagerForResourceType sets up a controller for a specific resource type.
|
||||
// This allows dynamic controller registration for any discovered resource type.
|
||||
func (r *SourceReconciler) SetupWithManagerForResourceType(
|
||||
mgr ctrl.Manager,
|
||||
gvk schema.GroupVersionKind,
|
||||
) error {
|
||||
// Create an unstructured object for this GVK
|
||||
obj := &unstructured.Unstructured{}
|
||||
obj.SetGroupVersionKind(gvk)
|
||||
|
||||
// Create unique controller name including version to avoid collisions
|
||||
// e.g., "HorizontalPodAutoscaler.v1.autoscaling"
|
||||
controllerName := gvk.Kind + "." + gvk.Version
|
||||
if gvk.Group != "" {
|
||||
controllerName += "." + gvk.Group
|
||||
}
|
||||
|
||||
// Create mirror object for watching
|
||||
mirrorObj := &unstructured.Unstructured{}
|
||||
mirrorObj.SetGroupVersionKind(gvk)
|
||||
|
||||
// Create predicates to only watch mirror deletions
|
||||
mirrorDeletePredicate := predicate.Funcs{
|
||||
CreateFunc: func(e event.CreateEvent) bool { return false },
|
||||
UpdateFunc: func(e event.UpdateEvent) bool { return false },
|
||||
DeleteFunc: func(e event.DeleteEvent) bool { return IsMirrorResource(e.Object) },
|
||||
GenericFunc: func(e event.GenericEvent) bool { return false },
|
||||
}
|
||||
|
||||
return ctrl.NewControllerManagedBy(mgr).
|
||||
For(obj).
|
||||
Named(controllerName).
|
||||
// Watch mirror resources - when deleted, enqueue source for reconciliation
|
||||
Watches(
|
||||
mirrorObj,
|
||||
handler.EnqueueRequestsFromMapFunc(r.mapMirrorToSource),
|
||||
builder.WithPredicates(mirrorDeletePredicate),
|
||||
).
|
||||
Complete(r)
|
||||
}
|
||||
|
||||
// mapMirrorToSource maps a mirror resource to its source for reconciliation.
|
||||
func (r *SourceReconciler) mapMirrorToSource(ctx context.Context, obj client.Object) []reconcile.Request {
|
||||
// Only process if this is a mirror
|
||||
if !IsMirrorResource(obj) {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get source reference from annotations
|
||||
sourceNs, sourceName, _, found := GetSourceReference(obj)
|
||||
if !found {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Enqueue reconciliation request for the source
|
||||
return []reconcile.Request{
|
||||
{
|
||||
NamespacedName: types.NamespacedName{
|
||||
Namespace: sourceNs,
|
||||
Name: sourceName,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,463 @@
|
||||
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)
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user