mirror of
https://github.com/lukaszraczylo/kubemirror.git
synced 2026-06-05 22:43:51 +00:00
initial commit
This commit is contained in:
@@ -0,0 +1,77 @@
|
||||
// Package config provides configuration for the kubemirror controller.
|
||||
package config
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// Config holds all configuration for the controller.
|
||||
type Config struct {
|
||||
// MetricsBindAddress is the address for the metrics endpoint
|
||||
MetricsBindAddress string
|
||||
// HealthProbeBindAddress is the address for health probes
|
||||
HealthProbeBindAddress string
|
||||
|
||||
// WatchNamespaces is the list of namespaces to watch (empty = all namespaces)
|
||||
WatchNamespaces []string
|
||||
// ExcludedNamespaces is the list of namespaces to never mirror to
|
||||
ExcludedNamespaces []string
|
||||
// MirroredResourceTypes is the list of resource types to mirror
|
||||
// If empty, defaults to Secret and ConfigMap only
|
||||
MirroredResourceTypes []ResourceType
|
||||
// DeniedResourceTypes is the deny-list of resource types (by name, for backward compatibility)
|
||||
DeniedResourceTypes []string
|
||||
|
||||
// LeaderElection configuration
|
||||
LeaderElection LeaderElectionConfig
|
||||
|
||||
// ReconcileInterval is how often to re-check all resources
|
||||
ReconcileInterval time.Duration
|
||||
|
||||
// WorkerThreads is the number of concurrent reconciliation workers
|
||||
WorkerThreads int
|
||||
// RateLimitBurst is the burst capacity for rate limiting
|
||||
RateLimitBurst int
|
||||
// MemoryLimitMB is the memory limit in megabytes
|
||||
MemoryLimitMB int
|
||||
|
||||
// DebounceDuration is the debounce window for source updates
|
||||
DebounceDuration time.Duration
|
||||
|
||||
// MaxTargetsPerResource is the maximum number of target namespaces per resource
|
||||
MaxTargetsPerResource int
|
||||
|
||||
// RateLimitQPS is the maximum queries per second to the API server
|
||||
RateLimitQPS float32
|
||||
|
||||
// RequireNamespaceOptIn requires namespaces to have label for "all" mirrors
|
||||
RequireNamespaceOptIn bool
|
||||
// EnableAllKeyword enables the "all" keyword for target namespaces
|
||||
EnableAllKeyword bool
|
||||
// DryRun mode logs what would happen without actually making changes
|
||||
DryRun bool
|
||||
}
|
||||
|
||||
// LeaderElectionConfig holds leader election settings.
|
||||
type LeaderElectionConfig struct {
|
||||
// ResourceName is the name of the leader election resource
|
||||
ResourceName string
|
||||
// ResourceNamespace is the namespace for the leader election resource
|
||||
ResourceNamespace string
|
||||
|
||||
// LeaseDuration is the lease duration
|
||||
LeaseDuration time.Duration
|
||||
// RenewDeadline is the renew deadline
|
||||
RenewDeadline time.Duration
|
||||
// RetryPeriod is the retry period
|
||||
RetryPeriod time.Duration
|
||||
|
||||
// Enabled enables leader election
|
||||
Enabled bool
|
||||
}
|
||||
|
||||
// Validate checks if the configuration is valid.
|
||||
func (c *Config) Validate() error {
|
||||
// Add validation logic if needed
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||
)
|
||||
|
||||
// ResourceType defines a Kubernetes resource type to mirror.
|
||||
type ResourceType struct {
|
||||
Group string
|
||||
Version string
|
||||
Kind string
|
||||
}
|
||||
|
||||
// GroupVersionKind returns the GVK for this resource type.
|
||||
func (r ResourceType) GroupVersionKind() schema.GroupVersionKind {
|
||||
return schema.GroupVersionKind{
|
||||
Group: r.Group,
|
||||
Version: r.Version,
|
||||
Kind: r.Kind,
|
||||
}
|
||||
}
|
||||
|
||||
// String returns a string representation of the resource type.
|
||||
func (r ResourceType) String() string {
|
||||
if r.Group == "" {
|
||||
return fmt.Sprintf("%s.%s", r.Kind, r.Version)
|
||||
}
|
||||
return fmt.Sprintf("%s.%s.%s", r.Kind, r.Version, r.Group)
|
||||
}
|
||||
|
||||
// ParseResourceType parses a resource type string in the format "kind.version.group" or "kind.version".
|
||||
// Examples: "Secret.v1", "Ingress.v1.networking.k8s.io", "Middleware.v1alpha1.traefik.io"
|
||||
func ParseResourceType(s string) (ResourceType, error) {
|
||||
parts := strings.Split(s, ".")
|
||||
|
||||
switch len(parts) {
|
||||
case 2:
|
||||
// Core resources: "Secret.v1"
|
||||
return ResourceType{
|
||||
Kind: parts[0],
|
||||
Version: parts[1],
|
||||
Group: "",
|
||||
}, nil
|
||||
case 3:
|
||||
// Resources with group: "Ingress.v1.networking.k8s.io"
|
||||
return ResourceType{
|
||||
Kind: parts[0],
|
||||
Version: parts[1],
|
||||
Group: parts[2],
|
||||
}, nil
|
||||
default:
|
||||
// Support more complex groups with dots: "Middleware.v1alpha1.traefik.io"
|
||||
if len(parts) >= 3 {
|
||||
return ResourceType{
|
||||
Kind: parts[0],
|
||||
Version: parts[1],
|
||||
Group: strings.Join(parts[2:], "."),
|
||||
}, nil
|
||||
}
|
||||
return ResourceType{}, fmt.Errorf("invalid resource type format: %s (expected kind.version or kind.version.group)", s)
|
||||
}
|
||||
}
|
||||
|
||||
// DefaultResourceTypes returns the default set of resource types to mirror.
|
||||
func DefaultResourceTypes() []ResourceType {
|
||||
return []ResourceType{
|
||||
{Kind: "Secret", Version: "v1", Group: ""},
|
||||
{Kind: "ConfigMap", Version: "v1", Group: ""},
|
||||
}
|
||||
}
|
||||
|
||||
// ParseResourceTypes parses a comma-separated list of resource type strings.
|
||||
func ParseResourceTypes(s string) ([]ResourceType, error) {
|
||||
if s == "" {
|
||||
return DefaultResourceTypes(), nil
|
||||
}
|
||||
|
||||
parts := strings.Split(s, ",")
|
||||
types := make([]ResourceType, 0, len(parts))
|
||||
|
||||
for _, part := range parts {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
rt, err := ParseResourceType(part)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse resource type %q: %w", part, err)
|
||||
}
|
||||
types = append(types, rt)
|
||||
}
|
||||
|
||||
return types, nil
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||
)
|
||||
|
||||
func TestParseResourceType(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want ResourceType
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "core resource - Secret",
|
||||
input: "Secret.v1",
|
||||
want: ResourceType{
|
||||
Kind: "Secret",
|
||||
Version: "v1",
|
||||
Group: "",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "core resource - ConfigMap",
|
||||
input: "ConfigMap.v1",
|
||||
want: ResourceType{
|
||||
Kind: "ConfigMap",
|
||||
Version: "v1",
|
||||
Group: "",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "resource with simple group",
|
||||
input: "Ingress.v1.networking.k8s.io",
|
||||
want: ResourceType{
|
||||
Kind: "Ingress",
|
||||
Version: "v1",
|
||||
Group: "networking.k8s.io",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "resource with complex group",
|
||||
input: "Middleware.v1alpha1.traefik.io",
|
||||
want: ResourceType{
|
||||
Kind: "Middleware",
|
||||
Version: "v1alpha1",
|
||||
Group: "traefik.io",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "CRD example",
|
||||
input: "Certificate.v1.cert-manager.io",
|
||||
want: ResourceType{
|
||||
Kind: "Certificate",
|
||||
Version: "v1",
|
||||
Group: "cert-manager.io",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "invalid format - single part",
|
||||
input: "Secret",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "empty string",
|
||||
input: "",
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := ParseResourceType(tt.input)
|
||||
if tt.wantErr {
|
||||
assert.Error(t, err)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResourceType_GroupVersionKind(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
rt ResourceType
|
||||
want schema.GroupVersionKind
|
||||
}{
|
||||
{
|
||||
name: "core resource",
|
||||
rt: ResourceType{
|
||||
Kind: "Secret",
|
||||
Version: "v1",
|
||||
Group: "",
|
||||
},
|
||||
want: schema.GroupVersionKind{
|
||||
Kind: "Secret",
|
||||
Version: "v1",
|
||||
Group: "",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "resource with group",
|
||||
rt: ResourceType{
|
||||
Kind: "Ingress",
|
||||
Version: "v1",
|
||||
Group: "networking.k8s.io",
|
||||
},
|
||||
want: schema.GroupVersionKind{
|
||||
Kind: "Ingress",
|
||||
Version: "v1",
|
||||
Group: "networking.k8s.io",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := tt.rt.GroupVersionKind()
|
||||
assert.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResourceType_String(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
rt ResourceType
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "core resource",
|
||||
rt: ResourceType{
|
||||
Kind: "Secret",
|
||||
Version: "v1",
|
||||
Group: "",
|
||||
},
|
||||
want: "Secret.v1",
|
||||
},
|
||||
{
|
||||
name: "resource with group",
|
||||
rt: ResourceType{
|
||||
Kind: "Ingress",
|
||||
Version: "v1",
|
||||
Group: "networking.k8s.io",
|
||||
},
|
||||
want: "Ingress.v1.networking.k8s.io",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := tt.rt.String()
|
||||
assert.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseResourceTypes(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want []ResourceType
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "empty string returns defaults",
|
||||
input: "",
|
||||
want: DefaultResourceTypes(),
|
||||
},
|
||||
{
|
||||
name: "single resource type",
|
||||
input: "Secret.v1",
|
||||
want: []ResourceType{
|
||||
{Kind: "Secret", Version: "v1", Group: ""},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "multiple resource types",
|
||||
input: "Secret.v1,ConfigMap.v1,Ingress.v1.networking.k8s.io",
|
||||
want: []ResourceType{
|
||||
{Kind: "Secret", Version: "v1", Group: ""},
|
||||
{Kind: "ConfigMap", Version: "v1", Group: ""},
|
||||
{Kind: "Ingress", Version: "v1", Group: "networking.k8s.io"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "with whitespace",
|
||||
input: " Secret.v1 , ConfigMap.v1 ",
|
||||
want: []ResourceType{
|
||||
{Kind: "Secret", Version: "v1", Group: ""},
|
||||
{Kind: "ConfigMap", Version: "v1", Group: ""},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "invalid format in list",
|
||||
input: "Secret.v1,Invalid",
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := ParseResourceTypes(tt.input)
|
||||
if tt.wantErr {
|
||||
assert.Error(t, err)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultResourceTypes(t *testing.T) {
|
||||
defaults := DefaultResourceTypes()
|
||||
assert.Len(t, defaults, 2)
|
||||
assert.Contains(t, defaults, ResourceType{Kind: "Secret", Version: "v1", Group: ""})
|
||||
assert.Contains(t, defaults, ResourceType{Kind: "ConfigMap", Version: "v1", Group: ""})
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
// Package constants defines all annotation keys, label keys, and constant values
|
||||
// used by the kubemirror controller.
|
||||
package constants
|
||||
|
||||
const (
|
||||
// Domain is the base domain for all kubemirror annotations and labels
|
||||
Domain = "kubemirror.raczylo.com"
|
||||
|
||||
// Labels
|
||||
|
||||
// LabelEnabled is the label used for server-side filtering in watches.
|
||||
// Resources must have this label set to "true" to be processed by the controller.
|
||||
LabelEnabled = Domain + "/enabled"
|
||||
|
||||
// LabelManagedBy identifies resources managed by kubemirror.
|
||||
LabelManagedBy = Domain + "/managed-by"
|
||||
|
||||
// LabelMirror marks a resource as a mirror (target resource).
|
||||
LabelMirror = Domain + "/mirror"
|
||||
|
||||
// LabelAllowMirrors is set on namespaces to opt-in for "all" mirrors.
|
||||
LabelAllowMirrors = Domain + "/allow-mirrors"
|
||||
|
||||
// Annotations
|
||||
|
||||
// AnnotationSync marks a resource for mirroring when set to "true".
|
||||
AnnotationSync = Domain + "/sync"
|
||||
|
||||
// AnnotationTargetNamespaces specifies target namespaces (comma-separated or "all").
|
||||
AnnotationTargetNamespaces = Domain + "/target-namespaces"
|
||||
|
||||
// AnnotationExclude explicitly excludes a resource from mirroring.
|
||||
AnnotationExclude = Domain + "/exclude"
|
||||
|
||||
// AnnotationMaxTargets overrides the default maximum target limit per resource.
|
||||
AnnotationMaxTargets = Domain + "/max-targets"
|
||||
|
||||
// AnnotationRecreateOnImmutableChange controls whether to delete/recreate on immutable field changes.
|
||||
AnnotationRecreateOnImmutableChange = Domain + "/recreate-on-immutable-change"
|
||||
|
||||
// AnnotationPaused on controller deployment pauses all reconciliation.
|
||||
AnnotationPaused = Domain + "/paused"
|
||||
|
||||
// Source Resource Annotations (tracking)
|
||||
|
||||
// AnnotationContentHash stores the SHA256 hash of the source resource content.
|
||||
AnnotationContentHash = Domain + "/content-hash"
|
||||
|
||||
// Target Resource Annotations (ownership and tracking)
|
||||
|
||||
// AnnotationSourceNamespace stores the namespace of the source resource.
|
||||
AnnotationSourceNamespace = Domain + "/source-namespace"
|
||||
|
||||
// AnnotationSourceName stores the name of the source resource.
|
||||
AnnotationSourceName = Domain + "/source-name"
|
||||
|
||||
// AnnotationSourceUID stores the UID of the source resource.
|
||||
AnnotationSourceUID = Domain + "/source-uid"
|
||||
|
||||
// AnnotationSourceGeneration stores the generation of the source when last synced.
|
||||
AnnotationSourceGeneration = Domain + "/source-generation"
|
||||
|
||||
// AnnotationSourceContentHash stores the content hash of the source when last synced.
|
||||
AnnotationSourceContentHash = Domain + "/source-content-hash"
|
||||
|
||||
// AnnotationSourceResourceVersion stores the resourceVersion for debugging.
|
||||
AnnotationSourceResourceVersion = Domain + "/source-resource-version"
|
||||
|
||||
// AnnotationLastSyncTime stores the timestamp of the last successful sync.
|
||||
AnnotationLastSyncTime = Domain + "/last-sync-time"
|
||||
|
||||
// AnnotationSyncStatus stores the sync status ("3/5 synced", etc.).
|
||||
AnnotationSyncStatus = Domain + "/sync-status"
|
||||
|
||||
// AnnotationFailedTargets stores comma-separated list of failed target namespaces.
|
||||
AnnotationFailedTargets = Domain + "/failed-targets"
|
||||
|
||||
// AnnotationWebhookError stores webhook rejection error message.
|
||||
AnnotationWebhookError = Domain + "/webhook-error"
|
||||
|
||||
// AnnotationTargetNamespaceUID tracks the UID of the target namespace.
|
||||
AnnotationTargetNamespaceUID = Domain + "/target-namespace-uid"
|
||||
|
||||
// AnnotationDeletionAttempts tracks number of failed deletion attempts.
|
||||
AnnotationDeletionAttempts = Domain + "/deletion-attempts"
|
||||
|
||||
// Finalizers
|
||||
|
||||
// FinalizerName is the finalizer added to source resources.
|
||||
FinalizerName = Domain + "/finalizer"
|
||||
|
||||
// Controller Configuration
|
||||
|
||||
// ControllerName is the name of the controller (for field manager, metrics, etc.).
|
||||
ControllerName = "kubemirror"
|
||||
|
||||
// LeaderElectionID is the name of the leader election lease.
|
||||
LeaderElectionID = "kubemirror-controller-leader"
|
||||
|
||||
// Special Values
|
||||
|
||||
// TargetNamespacesAll is the special keyword for mirroring to all namespaces.
|
||||
TargetNamespacesAll = "all"
|
||||
|
||||
// TargetNamespacesAllLabeled mirrors to namespaces with allow-mirrors label.
|
||||
TargetNamespacesAllLabeled = "all-labeled"
|
||||
)
|
||||
|
||||
// Default System Namespaces (excluded by default)
|
||||
var (
|
||||
DefaultExcludedNamespaces = []string{
|
||||
"kube-system",
|
||||
"kube-public",
|
||||
"kube-node-lease",
|
||||
}
|
||||
|
||||
// Blacklisted Secret Types (never mirrored)
|
||||
BlacklistedSecretTypes = []string{
|
||||
"kubernetes.io/service-account-token",
|
||||
"bootstrap.kubernetes.io/token",
|
||||
"helm.sh/release.v1",
|
||||
}
|
||||
|
||||
// Default Denied Resource Types
|
||||
DefaultDeniedResourceTypes = []string{
|
||||
"events",
|
||||
"pods",
|
||||
"replicasets",
|
||||
"endpoints",
|
||||
"endpointslices",
|
||||
}
|
||||
)
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
// Package discovery provides automatic resource type discovery for Kubernetes clusters.
|
||||
package discovery
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||
"k8s.io/client-go/discovery"
|
||||
"k8s.io/client-go/rest"
|
||||
|
||||
"github.com/lukaszraczylo/kubemirror/pkg/config"
|
||||
)
|
||||
|
||||
// ResourceDiscovery discovers all mirrorable resource types in a cluster.
|
||||
type ResourceDiscovery struct {
|
||||
discoveryClient discovery.DiscoveryInterface
|
||||
}
|
||||
|
||||
// NewResourceDiscovery creates a new resource discovery client.
|
||||
func NewResourceDiscovery(cfg *rest.Config) (*ResourceDiscovery, error) {
|
||||
dc, err := discovery.NewDiscoveryClientForConfig(cfg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create discovery client: %w", err)
|
||||
}
|
||||
|
||||
return &ResourceDiscovery{
|
||||
discoveryClient: dc,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// DiscoverMirrorableResources discovers all resource types that can be mirrored.
|
||||
// It filters out resources that shouldn't be mirrored based on a deny list.
|
||||
func (d *ResourceDiscovery) DiscoverMirrorableResources(ctx context.Context) ([]config.ResourceType, error) {
|
||||
// Get all API resources in the cluster
|
||||
_, apiResourceLists, err := d.discoveryClient.ServerGroupsAndResources()
|
||||
if err != nil {
|
||||
// Partial errors are common (some APIs might not be fully available)
|
||||
// Continue with what we have
|
||||
if !discovery.IsGroupDiscoveryFailedError(err) {
|
||||
return nil, fmt.Errorf("failed to discover API resources: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
var resources []config.ResourceType
|
||||
seen := make(map[string]bool) // Deduplicate
|
||||
|
||||
for _, apiResourceList := range apiResourceLists {
|
||||
gv, err := schema.ParseGroupVersion(apiResourceList.GroupVersion)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, apiResource := range apiResourceList.APIResources {
|
||||
// Skip subresources (status, scale, etc.)
|
||||
if strings.Contains(apiResource.Name, "/") {
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip if not namespaced (we only mirror namespaced resources)
|
||||
if !apiResource.Namespaced {
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip if resource doesn't support required verbs
|
||||
if !supportsRequiredVerbs(apiResource.Verbs) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip denied resource types
|
||||
if isDeniedResourceType(apiResource.Kind) {
|
||||
continue
|
||||
}
|
||||
|
||||
rt := config.ResourceType{
|
||||
Group: gv.Group,
|
||||
Version: gv.Version,
|
||||
Kind: apiResource.Kind,
|
||||
}
|
||||
|
||||
// Deduplicate by string representation
|
||||
key := rt.String()
|
||||
if seen[key] {
|
||||
continue
|
||||
}
|
||||
seen[key] = true
|
||||
|
||||
resources = append(resources, rt)
|
||||
}
|
||||
}
|
||||
|
||||
return resources, nil
|
||||
}
|
||||
|
||||
// supportsRequiredVerbs checks if a resource supports the verbs needed for mirroring.
|
||||
func supportsRequiredVerbs(verbs metav1.Verbs) bool {
|
||||
required := []string{"get", "list", "watch", "create", "update", "delete"}
|
||||
verbSet := make(map[string]bool)
|
||||
for _, v := range verbs {
|
||||
verbSet[v] = true
|
||||
}
|
||||
|
||||
for _, req := range required {
|
||||
if !verbSet[req] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// isDeniedResourceType checks if a resource type should never be mirrored.
|
||||
var deniedKinds = map[string]bool{
|
||||
// Kubernetes core resources that shouldn't be mirrored
|
||||
"Pod": true,
|
||||
"Node": true,
|
||||
"Event": true,
|
||||
"Endpoints": true,
|
||||
"EndpointSlice": true,
|
||||
"ComponentStatus": true,
|
||||
"Binding": true,
|
||||
"ReplicationController": true, // Deprecated, use Deployment
|
||||
|
||||
// Resources that are auto-generated or managed
|
||||
"ControllerRevision": true,
|
||||
"PodMetrics": true,
|
||||
"NodeMetrics": true,
|
||||
|
||||
// Lease resources (used for leader election)
|
||||
"Lease": true,
|
||||
|
||||
// CSI and storage resources
|
||||
"CSIDriver": true,
|
||||
"CSINode": true,
|
||||
"CSIStorageCapacity": true,
|
||||
"VolumeAttachment": true,
|
||||
|
||||
// Cluster-scoped resources that we filtered out but double-check
|
||||
"Namespace": true,
|
||||
"PersistentVolume": true,
|
||||
"ClusterRole": true,
|
||||
"ClusterRoleBinding": true,
|
||||
"CustomResourceDefinition": true,
|
||||
"APIService": true,
|
||||
"ValidatingWebhookConfiguration": true,
|
||||
"MutatingWebhookConfiguration": true,
|
||||
}
|
||||
|
||||
func isDeniedResourceType(kind string) bool {
|
||||
return deniedKinds[kind]
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package discovery
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
)
|
||||
|
||||
func TestSupportsRequiredVerbs(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
verbs metav1.Verbs
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "all required verbs present",
|
||||
verbs: metav1.Verbs{"get", "list", "watch", "create", "update", "patch", "delete"},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "exact required verbs",
|
||||
verbs: metav1.Verbs{"get", "list", "watch", "create", "update", "delete"},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "missing create verb",
|
||||
verbs: metav1.Verbs{"get", "list", "watch", "update", "delete"},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "missing watch verb",
|
||||
verbs: metav1.Verbs{"get", "list", "create", "update", "delete"},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "read-only resource",
|
||||
verbs: metav1.Verbs{"get", "list", "watch"},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "empty verbs",
|
||||
verbs: metav1.Verbs{},
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := supportsRequiredVerbs(tt.verbs)
|
||||
assert.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsDeniedResourceType(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
kind string
|
||||
want bool
|
||||
}{
|
||||
// Should be denied
|
||||
{name: "Pod", kind: "Pod", want: true},
|
||||
{name: "Event", kind: "Event", want: true},
|
||||
{name: "Endpoints", kind: "Endpoints", want: true},
|
||||
{name: "Node", kind: "Node", want: true},
|
||||
{name: "Lease", kind: "Lease", want: true},
|
||||
{name: "Namespace", kind: "Namespace", want: true},
|
||||
{name: "ClusterRole", kind: "ClusterRole", want: true},
|
||||
|
||||
// Should NOT be denied
|
||||
{name: "Secret", kind: "Secret", want: false},
|
||||
{name: "ConfigMap", kind: "ConfigMap", want: false},
|
||||
{name: "Service", kind: "Service", want: false},
|
||||
{name: "Ingress", kind: "Ingress", want: false},
|
||||
{name: "Deployment", kind: "Deployment", want: false},
|
||||
{name: "StatefulSet", kind: "StatefulSet", want: false},
|
||||
{name: "Middleware", kind: "Middleware", want: false},
|
||||
{name: "Certificate", kind: "Certificate", want: false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := isDeniedResourceType(tt.kind)
|
||||
assert.Equal(t, tt.want, got, "isDeniedResourceType(%s) = %v, want %v", tt.kind, got, tt.want)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
package discovery
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/go-logr/logr"
|
||||
"sigs.k8s.io/controller-runtime/pkg/log"
|
||||
|
||||
"github.com/lukaszraczylo/kubemirror/pkg/config"
|
||||
)
|
||||
|
||||
// Manager handles periodic resource discovery and controller registration.
|
||||
type Manager struct {
|
||||
discovery *ResourceDiscovery
|
||||
logger logr.Logger
|
||||
currentResources []config.ResourceType
|
||||
interval time.Duration
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// NewManager creates a new discovery manager.
|
||||
func NewManager(discovery *ResourceDiscovery, interval time.Duration) *Manager {
|
||||
return &Manager{
|
||||
discovery: discovery,
|
||||
interval: interval,
|
||||
currentResources: []config.ResourceType{},
|
||||
}
|
||||
}
|
||||
|
||||
// Start begins periodic resource discovery.
|
||||
// It performs an initial discovery immediately, then rediscovers on the specified interval.
|
||||
func (m *Manager) Start(ctx context.Context) error {
|
||||
logger := log.FromContext(ctx).WithName("discovery-manager")
|
||||
m.logger = logger
|
||||
|
||||
// Initial discovery
|
||||
if err := m.discover(ctx); err != nil {
|
||||
return fmt.Errorf("initial resource discovery failed: %w", err)
|
||||
}
|
||||
|
||||
// Start periodic rediscovery
|
||||
go m.run(ctx)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetCurrentResources returns the currently discovered resource types.
|
||||
func (m *Manager) GetCurrentResources() []config.ResourceType {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
// Return a copy to prevent concurrent modification
|
||||
result := make([]config.ResourceType, len(m.currentResources))
|
||||
copy(result, m.currentResources)
|
||||
return result
|
||||
}
|
||||
|
||||
// run is the main discovery loop.
|
||||
func (m *Manager) run(ctx context.Context) {
|
||||
ticker := time.NewTicker(m.interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
logger := log.FromContext(ctx).WithName("discovery-manager")
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
logger.Info("discovery manager stopped due to context cancellation")
|
||||
return
|
||||
case <-ticker.C:
|
||||
if err := m.discover(ctx); err != nil {
|
||||
logger.Error(err, "periodic resource discovery failed")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// discover performs resource discovery and detects changes.
|
||||
func (m *Manager) discover(ctx context.Context) error {
|
||||
logger := log.FromContext(ctx).WithName("discovery-manager")
|
||||
|
||||
// Discover current resources
|
||||
discovered, err := m.discovery.DiscoverMirrorableResources(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to discover resources: %w", err)
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
// Detect changes
|
||||
added, removed := m.detectChanges(m.currentResources, discovered)
|
||||
|
||||
if len(added) > 0 {
|
||||
logger.Info("new resource types discovered",
|
||||
"count", len(added),
|
||||
"resources", resourceTypesToStrings(added),
|
||||
)
|
||||
}
|
||||
|
||||
if len(removed) > 0 {
|
||||
logger.Info("resource types removed",
|
||||
"count", len(removed),
|
||||
"resources", resourceTypesToStrings(removed),
|
||||
)
|
||||
}
|
||||
|
||||
if len(added) == 0 && len(removed) == 0 {
|
||||
logger.V(1).Info("no changes in discovered resources",
|
||||
"total", len(discovered),
|
||||
)
|
||||
}
|
||||
|
||||
// Update current resources
|
||||
m.currentResources = discovered
|
||||
|
||||
logger.Info("resource discovery completed",
|
||||
"total", len(discovered),
|
||||
"added", len(added),
|
||||
"removed", len(removed),
|
||||
)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// detectChanges compares old and new resource lists to find additions and removals.
|
||||
func (m *Manager) detectChanges(old, new []config.ResourceType) (added, removed []config.ResourceType) {
|
||||
oldMap := make(map[string]config.ResourceType)
|
||||
newMap := make(map[string]config.ResourceType)
|
||||
|
||||
for _, rt := range old {
|
||||
oldMap[rt.String()] = rt
|
||||
}
|
||||
|
||||
for _, rt := range new {
|
||||
newMap[rt.String()] = rt
|
||||
}
|
||||
|
||||
// Find added resources
|
||||
for key, rt := range newMap {
|
||||
if _, exists := oldMap[key]; !exists {
|
||||
added = append(added, rt)
|
||||
}
|
||||
}
|
||||
|
||||
// Find removed resources
|
||||
for key, rt := range oldMap {
|
||||
if _, exists := newMap[key]; !exists {
|
||||
removed = append(removed, rt)
|
||||
}
|
||||
}
|
||||
|
||||
return added, removed
|
||||
}
|
||||
|
||||
// resourceTypesToStrings converts a slice of ResourceType to strings for logging.
|
||||
func resourceTypesToStrings(resources []config.ResourceType) []string {
|
||||
result := make([]string, len(resources))
|
||||
for i, rt := range resources {
|
||||
result[i] = rt.String()
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// WaitForInitialDiscovery blocks until the first discovery completes.
|
||||
// Useful for ensuring resources are discovered before starting controllers.
|
||||
func (m *Manager) WaitForInitialDiscovery(ctx context.Context, timeout time.Duration) error {
|
||||
ctx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
|
||||
ticker := time.NewTicker(100 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return fmt.Errorf("timeout waiting for initial discovery")
|
||||
case <-ticker.C:
|
||||
m.mu.RLock()
|
||||
hasResources := len(m.currentResources) > 0
|
||||
m.mu.RUnlock()
|
||||
|
||||
if hasResources {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
package discovery
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/lukaszraczylo/kubemirror/pkg/config"
|
||||
)
|
||||
|
||||
func TestDetectChanges(t *testing.T) {
|
||||
m := &Manager{}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
old []config.ResourceType
|
||||
new []config.ResourceType
|
||||
wantAdded []config.ResourceType
|
||||
wantRemoved []config.ResourceType
|
||||
}{
|
||||
{
|
||||
name: "no changes",
|
||||
old: []config.ResourceType{
|
||||
{Kind: "Secret", Version: "v1", Group: ""},
|
||||
{Kind: "ConfigMap", Version: "v1", Group: ""},
|
||||
},
|
||||
new: []config.ResourceType{
|
||||
{Kind: "Secret", Version: "v1", Group: ""},
|
||||
{Kind: "ConfigMap", Version: "v1", Group: ""},
|
||||
},
|
||||
wantAdded: nil,
|
||||
wantRemoved: nil,
|
||||
},
|
||||
{
|
||||
name: "new resource added",
|
||||
old: []config.ResourceType{
|
||||
{Kind: "Secret", Version: "v1", Group: ""},
|
||||
},
|
||||
new: []config.ResourceType{
|
||||
{Kind: "Secret", Version: "v1", Group: ""},
|
||||
{Kind: "Service", Version: "v1", Group: ""},
|
||||
},
|
||||
wantAdded: []config.ResourceType{
|
||||
{Kind: "Service", Version: "v1", Group: ""},
|
||||
},
|
||||
wantRemoved: nil,
|
||||
},
|
||||
{
|
||||
name: "resource removed",
|
||||
old: []config.ResourceType{
|
||||
{Kind: "Secret", Version: "v1", Group: ""},
|
||||
{Kind: "ConfigMap", Version: "v1", Group: ""},
|
||||
},
|
||||
new: []config.ResourceType{
|
||||
{Kind: "Secret", Version: "v1", Group: ""},
|
||||
},
|
||||
wantAdded: nil,
|
||||
wantRemoved: []config.ResourceType{
|
||||
{Kind: "ConfigMap", Version: "v1", Group: ""},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "multiple changes",
|
||||
old: []config.ResourceType{
|
||||
{Kind: "Secret", Version: "v1", Group: ""},
|
||||
{Kind: "ConfigMap", Version: "v1", Group: ""},
|
||||
},
|
||||
new: []config.ResourceType{
|
||||
{Kind: "Secret", Version: "v1", Group: ""},
|
||||
{Kind: "Service", Version: "v1", Group: ""},
|
||||
{Kind: "Ingress", Version: "v1", Group: "networking.k8s.io"},
|
||||
},
|
||||
wantAdded: []config.ResourceType{
|
||||
{Kind: "Service", Version: "v1", Group: ""},
|
||||
{Kind: "Ingress", Version: "v1", Group: "networking.k8s.io"},
|
||||
},
|
||||
wantRemoved: []config.ResourceType{
|
||||
{Kind: "ConfigMap", Version: "v1", Group: ""},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "complete replacement",
|
||||
old: []config.ResourceType{
|
||||
{Kind: "Secret", Version: "v1", Group: ""},
|
||||
{Kind: "ConfigMap", Version: "v1", Group: ""},
|
||||
},
|
||||
new: []config.ResourceType{
|
||||
{Kind: "Service", Version: "v1", Group: ""},
|
||||
{Kind: "Ingress", Version: "v1", Group: "networking.k8s.io"},
|
||||
},
|
||||
wantAdded: []config.ResourceType{
|
||||
{Kind: "Service", Version: "v1", Group: ""},
|
||||
{Kind: "Ingress", Version: "v1", Group: "networking.k8s.io"},
|
||||
},
|
||||
wantRemoved: []config.ResourceType{
|
||||
{Kind: "Secret", Version: "v1", Group: ""},
|
||||
{Kind: "ConfigMap", Version: "v1", Group: ""},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "from empty to populated",
|
||||
old: []config.ResourceType{},
|
||||
new: []config.ResourceType{
|
||||
{Kind: "Secret", Version: "v1", Group: ""},
|
||||
{Kind: "ConfigMap", Version: "v1", Group: ""},
|
||||
},
|
||||
wantAdded: []config.ResourceType{
|
||||
{Kind: "Secret", Version: "v1", Group: ""},
|
||||
{Kind: "ConfigMap", Version: "v1", Group: ""},
|
||||
},
|
||||
wantRemoved: nil,
|
||||
},
|
||||
{
|
||||
name: "from populated to empty",
|
||||
old: []config.ResourceType{
|
||||
{Kind: "Secret", Version: "v1", Group: ""},
|
||||
{Kind: "ConfigMap", Version: "v1", Group: ""},
|
||||
},
|
||||
new: []config.ResourceType{},
|
||||
wantAdded: nil,
|
||||
wantRemoved: []config.ResourceType{
|
||||
{Kind: "Secret", Version: "v1", Group: ""},
|
||||
{Kind: "ConfigMap", Version: "v1", Group: ""},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
gotAdded, gotRemoved := m.detectChanges(tt.old, tt.new)
|
||||
|
||||
// Sort for consistent comparison
|
||||
assert.ElementsMatch(t, tt.wantAdded, gotAdded, "added resources mismatch")
|
||||
assert.ElementsMatch(t, tt.wantRemoved, gotRemoved, "removed resources mismatch")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResourceTypesToStrings(t *testing.T) {
|
||||
resources := []config.ResourceType{
|
||||
{Kind: "Secret", Version: "v1", Group: ""},
|
||||
{Kind: "Ingress", Version: "v1", Group: "networking.k8s.io"},
|
||||
{Kind: "Middleware", Version: "v1alpha1", Group: "traefik.io"},
|
||||
}
|
||||
|
||||
want := []string{
|
||||
"Secret.v1",
|
||||
"Ingress.v1.networking.k8s.io",
|
||||
"Middleware.v1alpha1.traefik.io",
|
||||
}
|
||||
|
||||
got := resourceTypesToStrings(resources)
|
||||
assert.Equal(t, want, got)
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
// Package filter provides namespace filtering and pattern matching functionality.
|
||||
package filter
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/lukaszraczylo/kubemirror/pkg/constants"
|
||||
)
|
||||
|
||||
// NamespaceFilter handles namespace filtering logic including patterns and exclusions.
|
||||
type NamespaceFilter struct {
|
||||
excludedNamespaces map[string]bool
|
||||
includedPatterns []string
|
||||
}
|
||||
|
||||
// NewNamespaceFilter creates a new NamespaceFilter with the given exclusions and inclusions.
|
||||
func NewNamespaceFilter(excluded, included []string) *NamespaceFilter {
|
||||
excludedMap := make(map[string]bool)
|
||||
for _, ns := range excluded {
|
||||
excludedMap[ns] = true
|
||||
}
|
||||
|
||||
return &NamespaceFilter{
|
||||
excludedNamespaces: excludedMap,
|
||||
includedPatterns: included,
|
||||
}
|
||||
}
|
||||
|
||||
// IsAllowed checks if a namespace is allowed based on filters.
|
||||
// Returns true if the namespace passes all filters.
|
||||
func (nf *NamespaceFilter) IsAllowed(namespace string) bool {
|
||||
// Check if explicitly excluded
|
||||
if nf.excludedNamespaces[namespace] {
|
||||
return false
|
||||
}
|
||||
|
||||
// If no include patterns specified, allow all (except excluded)
|
||||
if len(nf.includedPatterns) == 0 {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check if matches any include pattern
|
||||
for _, pattern := range nf.includedPatterns {
|
||||
if matchesPattern(namespace, pattern) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// MatchesPattern checks if a namespace name matches the given pattern.
|
||||
// Supports glob-style patterns: "app-*", "*-prod", "stage-*-db"
|
||||
func matchesPattern(namespace, pattern string) bool {
|
||||
// Direct match
|
||||
if namespace == pattern {
|
||||
return true
|
||||
}
|
||||
|
||||
// Use filepath.Match for glob-style matching
|
||||
// filepath.Match supports * (any sequence) and ? (single char)
|
||||
matched, err := filepath.Match(pattern, namespace)
|
||||
if err != nil {
|
||||
// Invalid pattern, no match
|
||||
return false
|
||||
}
|
||||
|
||||
return matched
|
||||
}
|
||||
|
||||
// ParseTargetNamespaces parses the target-namespaces annotation value.
|
||||
// Returns a list of namespace patterns or special keywords.
|
||||
// Input: "ns1,ns2,app-*" or "all" or "all-labeled"
|
||||
func ParseTargetNamespaces(value string) []string {
|
||||
if value == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Trim whitespace
|
||||
value = strings.TrimSpace(value)
|
||||
|
||||
// Handle special keywords
|
||||
if value == constants.TargetNamespacesAll || value == constants.TargetNamespacesAllLabeled {
|
||||
return []string{value}
|
||||
}
|
||||
|
||||
// Split by comma and trim each entry
|
||||
parts := strings.Split(value, ",")
|
||||
result := make([]string, 0, len(parts))
|
||||
|
||||
for _, part := range parts {
|
||||
part = strings.TrimSpace(part)
|
||||
if part != "" {
|
||||
result = append(result, part)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// ResolveTargetNamespaces resolves namespace patterns to concrete namespace names.
|
||||
// Handles "all", "all-labeled", and glob patterns.
|
||||
// Parameters:
|
||||
// - patterns: namespace patterns from annotation
|
||||
// - allNamespaces: list of all namespaces in cluster
|
||||
// - allowMirrorsNamespaces: namespaces with allow-mirrors label
|
||||
// - sourceNamespace: exclude this namespace to prevent self-copy
|
||||
// - filter: namespace filter for exclusions
|
||||
//
|
||||
// Returns: list of concrete target namespace names
|
||||
func ResolveTargetNamespaces(
|
||||
patterns []string,
|
||||
allNamespaces []string,
|
||||
allowMirrorsNamespaces []string,
|
||||
sourceNamespace string,
|
||||
filter *NamespaceFilter,
|
||||
) []string {
|
||||
if len(patterns) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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)
|
||||
for _, ns := range allNamespaces {
|
||||
if ns != sourceNamespace && filter.IsAllowed(ns) {
|
||||
targetMap[ns] = true
|
||||
}
|
||||
}
|
||||
|
||||
case constants.TargetNamespacesAllLabeled:
|
||||
// Mirror only to namespaces with allow-mirrors label
|
||||
for _, ns := range allowMirrorsNamespaces {
|
||||
if ns != sourceNamespace && filter.IsAllowed(ns) {
|
||||
targetMap[ns] = true
|
||||
}
|
||||
}
|
||||
|
||||
default:
|
||||
// Check if it's a pattern or direct namespace name
|
||||
if strings.Contains(pattern, "*") || strings.Contains(pattern, "?") {
|
||||
// It's a glob pattern - match against all namespaces
|
||||
for _, ns := range allNamespaces {
|
||||
if matchesPattern(ns, pattern) && ns != sourceNamespace && filter.IsAllowed(ns) {
|
||||
targetMap[ns] = true
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Direct namespace name
|
||||
if pattern != sourceNamespace && filter.IsAllowed(pattern) {
|
||||
targetMap[pattern] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Convert map to slice
|
||||
result := make([]string, 0, len(targetMap))
|
||||
for ns := range targetMap {
|
||||
result = append(result, ns)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,587 @@
|
||||
package filter
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/lukaszraczylo/kubemirror/pkg/constants"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestNamespaceFilter_IsAllowed(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
namespace string
|
||||
excluded []string
|
||||
included []string
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "allow when no filters",
|
||||
excluded: []string{},
|
||||
included: []string{},
|
||||
namespace: "app1",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "deny when explicitly excluded",
|
||||
excluded: []string{"kube-system", "kube-public"},
|
||||
included: []string{},
|
||||
namespace: "kube-system",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "allow when not excluded",
|
||||
excluded: []string{"kube-system"},
|
||||
included: []string{},
|
||||
namespace: "app1",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "allow when matches include pattern",
|
||||
excluded: []string{},
|
||||
included: []string{"app-*"},
|
||||
namespace: "app-frontend",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "deny when doesn't match include pattern",
|
||||
excluded: []string{},
|
||||
included: []string{"app-*"},
|
||||
namespace: "backend",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "deny when excluded even if matches include",
|
||||
excluded: []string{"app-bad"},
|
||||
included: []string{"app-*"},
|
||||
namespace: "app-bad",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "allow when matches one of multiple patterns",
|
||||
excluded: []string{},
|
||||
included: []string{"app-*", "prod-*"},
|
||||
namespace: "prod-db",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "allow direct match in include list",
|
||||
excluded: []string{},
|
||||
included: []string{"specific-ns"},
|
||||
namespace: "specific-ns",
|
||||
want: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
nf := NewNamespaceFilter(tt.excluded, tt.included)
|
||||
got := nf.IsAllowed(tt.namespace)
|
||||
assert.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchesPattern(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
namespace string
|
||||
pattern string
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "exact match",
|
||||
namespace: "app-frontend",
|
||||
pattern: "app-frontend",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "wildcard at end",
|
||||
namespace: "app-frontend",
|
||||
pattern: "app-*",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "wildcard at start",
|
||||
namespace: "app-frontend",
|
||||
pattern: "*-frontend",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "wildcard in middle",
|
||||
namespace: "app-prod-frontend",
|
||||
pattern: "app-*-frontend",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "multiple wildcards",
|
||||
namespace: "my-app-prod-db",
|
||||
pattern: "*-app-*-db",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "single char wildcard",
|
||||
namespace: "app1",
|
||||
pattern: "app?",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "no match",
|
||||
namespace: "backend",
|
||||
pattern: "app-*",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "empty pattern matches empty string",
|
||||
namespace: "",
|
||||
pattern: "",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "pattern doesn't match different namespace",
|
||||
namespace: "production-app",
|
||||
pattern: "prod-*",
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := matchesPattern(tt.namespace, tt.pattern)
|
||||
assert.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTargetNamespaces(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
value string
|
||||
want []string
|
||||
}{
|
||||
{
|
||||
name: "empty string",
|
||||
value: "",
|
||||
want: nil,
|
||||
},
|
||||
{
|
||||
name: "single namespace",
|
||||
value: "app1",
|
||||
want: []string{"app1"},
|
||||
},
|
||||
{
|
||||
name: "multiple namespaces",
|
||||
value: "app1,app2,app3",
|
||||
want: []string{"app1", "app2", "app3"},
|
||||
},
|
||||
{
|
||||
name: "with whitespace",
|
||||
value: "app1, app2 , app3",
|
||||
want: []string{"app1", "app2", "app3"},
|
||||
},
|
||||
{
|
||||
name: "special keyword 'all'",
|
||||
value: "all",
|
||||
want: []string{"all"},
|
||||
},
|
||||
{
|
||||
name: "special keyword 'all-labeled'",
|
||||
value: "all-labeled",
|
||||
want: []string{"all-labeled"},
|
||||
},
|
||||
{
|
||||
name: "mixed patterns",
|
||||
value: "app1,app-*,prod-*",
|
||||
want: []string{"app1", "app-*", "prod-*"},
|
||||
},
|
||||
{
|
||||
name: "trailing comma",
|
||||
value: "app1,app2,",
|
||||
want: []string{"app1", "app2"},
|
||||
},
|
||||
{
|
||||
name: "empty entries ignored",
|
||||
value: "app1,,app2",
|
||||
want: []string{"app1", "app2"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := ParseTargetNamespaces(tt.value)
|
||||
assert.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveTargetNamespaces(t *testing.T) {
|
||||
allNamespaces := []string{"app1", "app2", "app-frontend", "app-backend", "prod-db", "prod-api", "kube-system", "default"}
|
||||
allowMirrorsNamespaces := []string{"app1", "app-frontend", "prod-db"}
|
||||
excludeFilter := NewNamespaceFilter([]string{"kube-system"}, []string{})
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
patterns []string
|
||||
allNamespaces []string
|
||||
allowMirrorsNamespaces []string
|
||||
sourceNamespace string
|
||||
filter *NamespaceFilter
|
||||
wantContains []string
|
||||
wantNotContains []string
|
||||
}{
|
||||
{
|
||||
name: "empty patterns",
|
||||
patterns: []string{},
|
||||
allNamespaces: allNamespaces,
|
||||
allowMirrorsNamespaces: allowMirrorsNamespaces,
|
||||
sourceNamespace: "default",
|
||||
filter: excludeFilter,
|
||||
wantContains: []string{},
|
||||
wantNotContains: allNamespaces,
|
||||
},
|
||||
{
|
||||
name: "all keyword",
|
||||
patterns: []string{constants.TargetNamespacesAll},
|
||||
allNamespaces: allNamespaces,
|
||||
allowMirrorsNamespaces: allowMirrorsNamespaces,
|
||||
sourceNamespace: "default",
|
||||
filter: excludeFilter,
|
||||
wantContains: []string{"app1", "app2", "app-frontend", "prod-db"},
|
||||
wantNotContains: []string{"default", "kube-system"}, // excluded: source and kube-system
|
||||
},
|
||||
{
|
||||
name: "all-labeled keyword",
|
||||
patterns: []string{constants.TargetNamespacesAllLabeled},
|
||||
allNamespaces: allNamespaces,
|
||||
allowMirrorsNamespaces: allowMirrorsNamespaces,
|
||||
sourceNamespace: "default",
|
||||
filter: excludeFilter,
|
||||
wantContains: []string{"app1", "app-frontend", "prod-db"},
|
||||
wantNotContains: []string{"app2", "app-backend", "default"},
|
||||
},
|
||||
{
|
||||
name: "glob pattern app-*",
|
||||
patterns: []string{"app-*"},
|
||||
allNamespaces: allNamespaces,
|
||||
allowMirrorsNamespaces: allowMirrorsNamespaces,
|
||||
sourceNamespace: "default",
|
||||
filter: excludeFilter,
|
||||
wantContains: []string{"app-frontend", "app-backend"},
|
||||
wantNotContains: []string{"app1", "app2", "prod-db"},
|
||||
},
|
||||
{
|
||||
name: "multiple patterns",
|
||||
patterns: []string{"app-*", "prod-*"},
|
||||
allNamespaces: allNamespaces,
|
||||
allowMirrorsNamespaces: allowMirrorsNamespaces,
|
||||
sourceNamespace: "default",
|
||||
filter: excludeFilter,
|
||||
wantContains: []string{"app-frontend", "app-backend", "prod-db", "prod-api"},
|
||||
wantNotContains: []string{"app1", "app2", "default"},
|
||||
},
|
||||
{
|
||||
name: "direct namespace names",
|
||||
patterns: []string{"app1", "app2"},
|
||||
allNamespaces: allNamespaces,
|
||||
allowMirrorsNamespaces: allowMirrorsNamespaces,
|
||||
sourceNamespace: "default",
|
||||
filter: excludeFilter,
|
||||
wantContains: []string{"app1", "app2"},
|
||||
wantNotContains: []string{"app-frontend", "prod-db", "default"},
|
||||
},
|
||||
{
|
||||
name: "exclude source namespace",
|
||||
patterns: []string{"app1"},
|
||||
allNamespaces: allNamespaces,
|
||||
allowMirrorsNamespaces: allowMirrorsNamespaces,
|
||||
sourceNamespace: "app1",
|
||||
filter: excludeFilter,
|
||||
wantContains: []string{},
|
||||
wantNotContains: []string{"app1"}, // app1 is source, excluded
|
||||
},
|
||||
{
|
||||
name: "deduplication",
|
||||
patterns: []string{"app-*", "app-frontend"}, // app-frontend matches both
|
||||
allNamespaces: allNamespaces,
|
||||
allowMirrorsNamespaces: allowMirrorsNamespaces,
|
||||
sourceNamespace: "default",
|
||||
filter: excludeFilter,
|
||||
wantContains: []string{"app-frontend", "app-backend"},
|
||||
wantNotContains: []string{"app1"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := ResolveTargetNamespaces(
|
||||
tt.patterns,
|
||||
tt.allNamespaces,
|
||||
tt.allowMirrorsNamespaces,
|
||||
tt.sourceNamespace,
|
||||
tt.filter,
|
||||
)
|
||||
|
||||
// Check that all expected namespaces are present
|
||||
for _, ns := range tt.wantContains {
|
||||
assert.Contains(t, got, ns, "should contain %s", ns)
|
||||
}
|
||||
|
||||
// Check that unwanted namespaces are not present
|
||||
for _, ns := range tt.wantNotContains {
|
||||
assert.NotContains(t, got, ns, "should not contain %s", ns)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Edge case tests
|
||||
func TestResolveTargetNamespaces_EdgeCases(t *testing.T) {
|
||||
t.Run("no namespaces in cluster", func(t *testing.T) {
|
||||
got := ResolveTargetNamespaces(
|
||||
[]string{"all"},
|
||||
[]string{},
|
||||
[]string{},
|
||||
"default",
|
||||
NewNamespaceFilter([]string{}, []string{}),
|
||||
)
|
||||
assert.Empty(t, got)
|
||||
})
|
||||
|
||||
t.Run("invalid pattern doesn't crash", func(t *testing.T) {
|
||||
// filepath.Match should handle this gracefully
|
||||
got := ResolveTargetNamespaces(
|
||||
[]string{"[invalid"},
|
||||
[]string{"app1"},
|
||||
[]string{},
|
||||
"default",
|
||||
NewNamespaceFilter([]string{}, []string{}),
|
||||
)
|
||||
assert.NotNil(t, got)
|
||||
})
|
||||
|
||||
t.Run("all excludes everything when filter denies all", func(t *testing.T) {
|
||||
strictFilter := NewNamespaceFilter([]string{}, []string{"specific-ns"})
|
||||
got := ResolveTargetNamespaces(
|
||||
[]string{"all"},
|
||||
[]string{"app1", "app2", "app3"},
|
||||
[]string{},
|
||||
"default",
|
||||
strictFilter,
|
||||
)
|
||||
// Only "specific-ns" would be allowed, but it's not in allNamespaces
|
||||
assert.Empty(t, got)
|
||||
})
|
||||
}
|
||||
|
||||
// Benchmark tests for critical paths
|
||||
|
||||
func BenchmarkParseTargetNamespaces(b *testing.B) {
|
||||
tests := []struct {
|
||||
name string
|
||||
value string
|
||||
}{
|
||||
{
|
||||
name: "single namespace",
|
||||
value: "app1",
|
||||
},
|
||||
{
|
||||
name: "10 namespaces",
|
||||
value: "app1,app2,app3,app4,app5,app6,app7,app8,app9,app10",
|
||||
},
|
||||
{
|
||||
name: "50 namespaces with whitespace",
|
||||
value: "app1, app2, app3, app4, app5, app6, app7, app8, app9, app10, app11, app12, app13, app14, app15, app16, app17, app18, app19, app20, app21, app22, app23, app24, app25, app26, app27, app28, app29, app30, app31, app32, app33, app34, app35, app36, app37, app38, app39, app40, app41, app42, app43, app44, app45, app46, app47, app48, app49, app50",
|
||||
},
|
||||
{
|
||||
name: "mixed patterns",
|
||||
value: "app1,app-*,prod-*,staging-*,dev-*",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
b.Run(tt.name, func(b *testing.B) {
|
||||
b.ReportAllocs()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = ParseTargetNamespaces(tt.value)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkMatchesPattern(b *testing.B) {
|
||||
tests := []struct {
|
||||
name string
|
||||
namespace string
|
||||
pattern string
|
||||
}{
|
||||
{
|
||||
name: "exact match",
|
||||
namespace: "app-frontend",
|
||||
pattern: "app-frontend",
|
||||
},
|
||||
{
|
||||
name: "simple wildcard",
|
||||
namespace: "app-frontend",
|
||||
pattern: "app-*",
|
||||
},
|
||||
{
|
||||
name: "complex wildcard",
|
||||
namespace: "my-app-prod-db",
|
||||
pattern: "*-app-*-db",
|
||||
},
|
||||
{
|
||||
name: "no match",
|
||||
namespace: "production-api",
|
||||
pattern: "app-*",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
b.Run(tt.name, func(b *testing.B) {
|
||||
b.ReportAllocs()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = matchesPattern(tt.namespace, tt.pattern)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkNamespaceFilter_IsAllowed(b *testing.B) {
|
||||
tests := []struct {
|
||||
name string
|
||||
filter *NamespaceFilter
|
||||
namespace string
|
||||
}{
|
||||
{
|
||||
name: "no filters (always allow)",
|
||||
filter: NewNamespaceFilter([]string{}, []string{}),
|
||||
namespace: "app1",
|
||||
},
|
||||
{
|
||||
name: "simple exclusion",
|
||||
filter: NewNamespaceFilter([]string{"kube-system", "kube-public", "kube-node-lease"}, []string{}),
|
||||
namespace: "app1",
|
||||
},
|
||||
{
|
||||
name: "pattern inclusion",
|
||||
filter: NewNamespaceFilter([]string{}, []string{"app-*", "prod-*"}),
|
||||
namespace: "app-frontend",
|
||||
},
|
||||
{
|
||||
name: "complex filtering",
|
||||
filter: NewNamespaceFilter([]string{"kube-system", "test-*"}, []string{"app-*", "prod-*"}),
|
||||
namespace: "prod-api",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
b.Run(tt.name, func(b *testing.B) {
|
||||
b.ReportAllocs()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = tt.filter.IsAllowed(tt.namespace)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkResolveTargetNamespaces(b *testing.B) {
|
||||
// Generate realistic namespace list
|
||||
allNamespaces := make([]string, 100)
|
||||
for i := 0; i < 100; i++ {
|
||||
if i < 30 {
|
||||
allNamespaces[i] = fmt.Sprintf("app-%d", i)
|
||||
} else if i < 60 {
|
||||
allNamespaces[i] = fmt.Sprintf("prod-%d", i)
|
||||
} else if i < 90 {
|
||||
allNamespaces[i] = fmt.Sprintf("staging-%d", i)
|
||||
} else {
|
||||
allNamespaces[i] = fmt.Sprintf("test-%d", i)
|
||||
}
|
||||
}
|
||||
|
||||
allowMirrorsNamespaces := allNamespaces[:50] // Half have opt-in label
|
||||
filter := NewNamespaceFilter([]string{"kube-system", "kube-public"}, []string{})
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
patterns []string
|
||||
}{
|
||||
{
|
||||
name: "all keyword",
|
||||
patterns: []string{constants.TargetNamespacesAll},
|
||||
},
|
||||
{
|
||||
name: "all-labeled keyword",
|
||||
patterns: []string{constants.TargetNamespacesAllLabeled},
|
||||
},
|
||||
{
|
||||
name: "single pattern",
|
||||
patterns: []string{"app-*"},
|
||||
},
|
||||
{
|
||||
name: "multiple patterns",
|
||||
patterns: []string{"app-*", "prod-*", "staging-*"},
|
||||
},
|
||||
{
|
||||
name: "direct names",
|
||||
patterns: []string{"app-1", "app-2", "prod-1", "prod-2"},
|
||||
},
|
||||
{
|
||||
name: "mixed direct and patterns",
|
||||
patterns: []string{"app-1", "prod-*", "staging-5"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
b.Run(tt.name, func(b *testing.B) {
|
||||
b.ReportAllocs()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = ResolveTargetNamespaces(
|
||||
tt.patterns,
|
||||
allNamespaces,
|
||||
allowMirrorsNamespaces,
|
||||
"default",
|
||||
filter,
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkResolveTargetNamespaces_LargeScale(b *testing.B) {
|
||||
// Simulate large cluster (1000 namespaces)
|
||||
allNamespaces := make([]string, 1000)
|
||||
for i := 0; i < 1000; i++ {
|
||||
allNamespaces[i] = fmt.Sprintf("namespace-%d", i)
|
||||
}
|
||||
|
||||
allowMirrorsNamespaces := allNamespaces[:500]
|
||||
filter := NewNamespaceFilter(constants.DefaultExcludedNamespaces, []string{})
|
||||
|
||||
b.Run("1000 namespaces with 'all' keyword", func(b *testing.B) {
|
||||
b.ReportAllocs()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = ResolveTargetNamespaces(
|
||||
[]string{constants.TargetNamespacesAll},
|
||||
allNamespaces,
|
||||
allowMirrorsNamespaces,
|
||||
"default",
|
||||
filter,
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
b.Run("1000 namespaces with pattern matching", func(b *testing.B) {
|
||||
b.ReportAllocs()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = ResolveTargetNamespaces(
|
||||
[]string{"namespace-*"},
|
||||
allNamespaces,
|
||||
allowMirrorsNamespaces,
|
||||
"default",
|
||||
filter,
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
// Package hash provides content hashing functionality for detecting resource changes.
|
||||
package hash
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
)
|
||||
|
||||
// ComputeContentHash computes a SHA256 hash of the resource's actual content.
|
||||
// It excludes metadata fields (resourceVersion, managedFields, etc.) and status.
|
||||
// This detects actual content changes vs Kubernetes metadata changes.
|
||||
func ComputeContentHash(obj runtime.Object) (string, error) {
|
||||
content, err := extractContent(obj)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to extract content: %w", err)
|
||||
}
|
||||
|
||||
// Convert to JSON for consistent hashing
|
||||
jsonBytes, err := json.Marshal(content)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to marshal content: %w", err)
|
||||
}
|
||||
|
||||
// Compute SHA256
|
||||
hash := sha256.Sum256(jsonBytes)
|
||||
return hex.EncodeToString(hash[:]), nil
|
||||
}
|
||||
|
||||
// extractContent extracts only the content fields from a resource.
|
||||
// Excludes all metadata except name, namespace, labels, and annotations we care about.
|
||||
func extractContent(obj runtime.Object) (interface{}, error) {
|
||||
// Try typed resources first
|
||||
switch resource := obj.(type) {
|
||||
case *corev1.Secret:
|
||||
return extractSecretContent(resource), nil
|
||||
case *corev1.ConfigMap:
|
||||
return extractConfigMapContent(resource), nil
|
||||
default:
|
||||
// Fall back to unstructured for CRDs and unknown types
|
||||
return extractUnstructuredContent(obj)
|
||||
}
|
||||
}
|
||||
|
||||
// extractSecretContent extracts content from a Secret.
|
||||
func extractSecretContent(secret *corev1.Secret) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"type": string(secret.Type),
|
||||
"data": secret.Data,
|
||||
"stringData": secret.StringData,
|
||||
}
|
||||
}
|
||||
|
||||
// extractConfigMapContent extracts content from a ConfigMap.
|
||||
func extractConfigMapContent(cm *corev1.ConfigMap) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"data": cm.Data,
|
||||
"binaryData": cm.BinaryData,
|
||||
}
|
||||
}
|
||||
|
||||
// extractUnstructuredContent extracts content from an unstructured resource (CRDs, etc.).
|
||||
func extractUnstructuredContent(obj runtime.Object) (interface{}, error) {
|
||||
// Convert to unstructured
|
||||
unstructuredObj, err := runtime.DefaultUnstructuredConverter.ToUnstructured(obj)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to convert to unstructured: %w", err)
|
||||
}
|
||||
|
||||
u := &unstructured.Unstructured{Object: unstructuredObj}
|
||||
|
||||
// Make a deep copy to avoid race conditions when accessing nested fields
|
||||
// NestedMap modifies the underlying map, so we need our own copy
|
||||
uCopy := u.DeepCopy()
|
||||
|
||||
// Extract spec (most resources have spec)
|
||||
spec, found, err := unstructured.NestedMap(uCopy.Object, "spec")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to extract spec: %w", err)
|
||||
}
|
||||
|
||||
content := make(map[string]interface{})
|
||||
if found {
|
||||
content["spec"] = spec
|
||||
}
|
||||
|
||||
// For resources without spec, include all fields except metadata and status
|
||||
if !found {
|
||||
for key, value := range uCopy.Object {
|
||||
if key != "metadata" && key != "status" && key != "apiVersion" && key != "kind" {
|
||||
content[key] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return content, nil
|
||||
}
|
||||
|
||||
// NeedsSync determines if a target resource needs to be synced based on content changes.
|
||||
// It uses a multi-layer strategy:
|
||||
// 1. Check generation field (if available) - fastest
|
||||
// 2. Check content hash - universal
|
||||
func NeedsSync(source, target runtime.Object, targetAnnotations map[string]string) (bool, error) {
|
||||
// Layer 1: Generation-based check (for resources that support it)
|
||||
sourceGen := getGeneration(source)
|
||||
if sourceGen > 0 {
|
||||
targetSourceGen := targetAnnotations["source-generation"]
|
||||
if fmt.Sprintf("%d", sourceGen) != targetSourceGen {
|
||||
return true, nil // Generation changed
|
||||
}
|
||||
}
|
||||
|
||||
// Layer 2: Content hash check (works for all resources)
|
||||
sourceHash, err := ComputeContentHash(source)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to compute source hash: %w", err)
|
||||
}
|
||||
|
||||
targetSourceHash := targetAnnotations["source-content-hash"]
|
||||
if sourceHash != targetSourceHash {
|
||||
return true, nil // Content changed
|
||||
}
|
||||
|
||||
// No changes detected
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// getGeneration extracts the generation field from a resource if it exists.
|
||||
// Returns 0 if the resource doesn't have a generation field.
|
||||
func getGeneration(obj runtime.Object) int64 {
|
||||
// Convert to unstructured to access generation
|
||||
unstructuredObj, err := runtime.DefaultUnstructuredConverter.ToUnstructured(obj)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
u := &unstructured.Unstructured{Object: unstructuredObj}
|
||||
return u.GetGeneration()
|
||||
}
|
||||
@@ -0,0 +1,529 @@
|
||||
package hash
|
||||
|
||||
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"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
)
|
||||
|
||||
func TestComputeContentHash_Secret(t *testing.T) {
|
||||
tests := []struct {
|
||||
secret1 *corev1.Secret
|
||||
secret2 *corev1.Secret
|
||||
name string
|
||||
wantSame bool
|
||||
wantError bool
|
||||
}{
|
||||
{
|
||||
name: "identical secrets produce same hash",
|
||||
secret1: &corev1.Secret{
|
||||
Data: map[string][]byte{
|
||||
"password": []byte("secret123"),
|
||||
},
|
||||
Type: corev1.SecretTypeOpaque,
|
||||
},
|
||||
secret2: &corev1.Secret{
|
||||
Data: map[string][]byte{
|
||||
"password": []byte("secret123"),
|
||||
},
|
||||
Type: corev1.SecretTypeOpaque,
|
||||
},
|
||||
wantSame: true,
|
||||
wantError: false,
|
||||
},
|
||||
{
|
||||
name: "different data produces different hash",
|
||||
secret1: &corev1.Secret{
|
||||
Data: map[string][]byte{
|
||||
"password": []byte("secret123"),
|
||||
},
|
||||
},
|
||||
secret2: &corev1.Secret{
|
||||
Data: map[string][]byte{
|
||||
"password": []byte("different"),
|
||||
},
|
||||
},
|
||||
wantSame: false,
|
||||
wantError: false,
|
||||
},
|
||||
{
|
||||
name: "different type produces different hash",
|
||||
secret1: &corev1.Secret{
|
||||
Data: map[string][]byte{"key": []byte("value")},
|
||||
Type: corev1.SecretTypeOpaque,
|
||||
},
|
||||
secret2: &corev1.Secret{
|
||||
Data: map[string][]byte{"key": []byte("value")},
|
||||
Type: corev1.SecretTypeTLS,
|
||||
},
|
||||
wantSame: false,
|
||||
wantError: false,
|
||||
},
|
||||
{
|
||||
name: "metadata changes don't affect hash",
|
||||
secret1: &corev1.Secret{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "secret1",
|
||||
Namespace: "default",
|
||||
ResourceVersion: "100",
|
||||
Generation: 1,
|
||||
},
|
||||
Data: map[string][]byte{"key": []byte("value")},
|
||||
},
|
||||
secret2: &corev1.Secret{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "secret2",
|
||||
Namespace: "different",
|
||||
ResourceVersion: "200",
|
||||
Generation: 2,
|
||||
},
|
||||
Data: map[string][]byte{"key": []byte("value")},
|
||||
},
|
||||
wantSame: true,
|
||||
wantError: false,
|
||||
},
|
||||
{
|
||||
name: "stringData included in hash",
|
||||
secret1: &corev1.Secret{
|
||||
StringData: map[string]string{"key": "value"},
|
||||
},
|
||||
secret2: &corev1.Secret{
|
||||
StringData: map[string]string{"key": "different"},
|
||||
},
|
||||
wantSame: false,
|
||||
wantError: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
hash1, err1 := ComputeContentHash(tt.secret1)
|
||||
hash2, err2 := ComputeContentHash(tt.secret2)
|
||||
|
||||
if tt.wantError {
|
||||
require.Error(t, err1)
|
||||
require.Error(t, err2)
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(t, err1)
|
||||
require.NoError(t, err2)
|
||||
assert.NotEmpty(t, hash1)
|
||||
assert.NotEmpty(t, hash2)
|
||||
|
||||
if tt.wantSame {
|
||||
assert.Equal(t, hash1, hash2, "hashes should be identical")
|
||||
} else {
|
||||
assert.NotEqual(t, hash1, hash2, "hashes should be different")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeContentHash_ConfigMap(t *testing.T) {
|
||||
tests := []struct {
|
||||
cm1 *corev1.ConfigMap
|
||||
cm2 *corev1.ConfigMap
|
||||
name string
|
||||
wantSame bool
|
||||
wantError bool
|
||||
}{
|
||||
{
|
||||
name: "identical configmaps produce same hash",
|
||||
cm1: &corev1.ConfigMap{
|
||||
Data: map[string]string{
|
||||
"config.yaml": "setting: value",
|
||||
},
|
||||
},
|
||||
cm2: &corev1.ConfigMap{
|
||||
Data: map[string]string{
|
||||
"config.yaml": "setting: value",
|
||||
},
|
||||
},
|
||||
wantSame: true,
|
||||
wantError: false,
|
||||
},
|
||||
{
|
||||
name: "different data produces different hash",
|
||||
cm1: &corev1.ConfigMap{
|
||||
Data: map[string]string{
|
||||
"key": "value1",
|
||||
},
|
||||
},
|
||||
cm2: &corev1.ConfigMap{
|
||||
Data: map[string]string{
|
||||
"key": "value2",
|
||||
},
|
||||
},
|
||||
wantSame: false,
|
||||
wantError: false,
|
||||
},
|
||||
{
|
||||
name: "binaryData included in hash",
|
||||
cm1: &corev1.ConfigMap{
|
||||
BinaryData: map[string][]byte{
|
||||
"file": []byte{0x00, 0x01, 0x02},
|
||||
},
|
||||
},
|
||||
cm2: &corev1.ConfigMap{
|
||||
BinaryData: map[string][]byte{
|
||||
"file": []byte{0x00, 0x01, 0xFF},
|
||||
},
|
||||
},
|
||||
wantSame: false,
|
||||
wantError: false,
|
||||
},
|
||||
{
|
||||
name: "metadata changes don't affect hash",
|
||||
cm1: &corev1.ConfigMap{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
ResourceVersion: "100",
|
||||
Generation: 1,
|
||||
},
|
||||
Data: map[string]string{"key": "value"},
|
||||
},
|
||||
cm2: &corev1.ConfigMap{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
ResourceVersion: "200",
|
||||
Generation: 5,
|
||||
},
|
||||
Data: map[string]string{"key": "value"},
|
||||
},
|
||||
wantSame: true,
|
||||
wantError: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
hash1, err1 := ComputeContentHash(tt.cm1)
|
||||
hash2, err2 := ComputeContentHash(tt.cm2)
|
||||
|
||||
if tt.wantError {
|
||||
require.Error(t, err1)
|
||||
require.Error(t, err2)
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(t, err1)
|
||||
require.NoError(t, err2)
|
||||
assert.NotEmpty(t, hash1)
|
||||
assert.NotEmpty(t, hash2)
|
||||
|
||||
if tt.wantSame {
|
||||
assert.Equal(t, hash1, hash2)
|
||||
} else {
|
||||
assert.NotEqual(t, hash1, hash2)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeContentHash_Unstructured(t *testing.T) {
|
||||
tests := []struct {
|
||||
obj1 *unstructured.Unstructured
|
||||
obj2 *unstructured.Unstructured
|
||||
name string
|
||||
wantSame bool
|
||||
wantError bool
|
||||
}{
|
||||
{
|
||||
name: "identical specs produce same hash",
|
||||
obj1: &unstructured.Unstructured{
|
||||
Object: map[string]interface{}{
|
||||
"apiVersion": "v1",
|
||||
"kind": "Custom",
|
||||
"spec": map[string]interface{}{
|
||||
"field": "value",
|
||||
},
|
||||
},
|
||||
},
|
||||
obj2: &unstructured.Unstructured{
|
||||
Object: map[string]interface{}{
|
||||
"apiVersion": "v1",
|
||||
"kind": "Custom",
|
||||
"spec": map[string]interface{}{
|
||||
"field": "value",
|
||||
},
|
||||
},
|
||||
},
|
||||
wantSame: true,
|
||||
wantError: false,
|
||||
},
|
||||
{
|
||||
name: "different specs produce different hash",
|
||||
obj1: &unstructured.Unstructured{
|
||||
Object: map[string]interface{}{
|
||||
"spec": map[string]interface{}{
|
||||
"field": "value1",
|
||||
},
|
||||
},
|
||||
},
|
||||
obj2: &unstructured.Unstructured{
|
||||
Object: map[string]interface{}{
|
||||
"spec": map[string]interface{}{
|
||||
"field": "value2",
|
||||
},
|
||||
},
|
||||
},
|
||||
wantSame: false,
|
||||
wantError: false,
|
||||
},
|
||||
{
|
||||
name: "metadata excluded from hash",
|
||||
obj1: &unstructured.Unstructured{
|
||||
Object: map[string]interface{}{
|
||||
"metadata": map[string]interface{}{
|
||||
"resourceVersion": "100",
|
||||
},
|
||||
"spec": map[string]interface{}{
|
||||
"field": "value",
|
||||
},
|
||||
},
|
||||
},
|
||||
obj2: &unstructured.Unstructured{
|
||||
Object: map[string]interface{}{
|
||||
"metadata": map[string]interface{}{
|
||||
"resourceVersion": "200",
|
||||
},
|
||||
"spec": map[string]interface{}{
|
||||
"field": "value",
|
||||
},
|
||||
},
|
||||
},
|
||||
wantSame: true,
|
||||
wantError: false,
|
||||
},
|
||||
{
|
||||
name: "status excluded from hash",
|
||||
obj1: &unstructured.Unstructured{
|
||||
Object: map[string]interface{}{
|
||||
"spec": map[string]interface{}{
|
||||
"field": "value",
|
||||
},
|
||||
"status": map[string]interface{}{
|
||||
"condition": "Ready",
|
||||
},
|
||||
},
|
||||
},
|
||||
obj2: &unstructured.Unstructured{
|
||||
Object: map[string]interface{}{
|
||||
"spec": map[string]interface{}{
|
||||
"field": "value",
|
||||
},
|
||||
"status": map[string]interface{}{
|
||||
"condition": "NotReady",
|
||||
},
|
||||
},
|
||||
},
|
||||
wantSame: true,
|
||||
wantError: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
hash1, err1 := ComputeContentHash(tt.obj1)
|
||||
hash2, err2 := ComputeContentHash(tt.obj2)
|
||||
|
||||
if tt.wantError {
|
||||
require.Error(t, err1)
|
||||
require.Error(t, err2)
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(t, err1)
|
||||
require.NoError(t, err2)
|
||||
assert.NotEmpty(t, hash1)
|
||||
assert.NotEmpty(t, hash2)
|
||||
|
||||
if tt.wantSame {
|
||||
assert.Equal(t, hash1, hash2)
|
||||
} else {
|
||||
assert.NotEqual(t, hash1, hash2)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNeedsSync(t *testing.T) {
|
||||
tests := []struct {
|
||||
source runtime.Object
|
||||
target runtime.Object
|
||||
targetAnnotations map[string]string
|
||||
name string
|
||||
want bool
|
||||
wantError bool
|
||||
}{
|
||||
{
|
||||
name: "needs sync when generation changed",
|
||||
source: &unstructured.Unstructured{
|
||||
Object: map[string]interface{}{
|
||||
"metadata": map[string]interface{}{
|
||||
"generation": int64(5),
|
||||
},
|
||||
"spec": map[string]interface{}{
|
||||
"field": "value",
|
||||
},
|
||||
},
|
||||
},
|
||||
target: &unstructured.Unstructured{},
|
||||
targetAnnotations: map[string]string{
|
||||
"source-generation": "3",
|
||||
"source-content-hash": "abc123",
|
||||
},
|
||||
want: true,
|
||||
wantError: false,
|
||||
},
|
||||
{
|
||||
name: "doesn't need sync when generation same and hash same",
|
||||
source: &corev1.Secret{
|
||||
Data: map[string][]byte{"key": []byte("value")},
|
||||
},
|
||||
target: &corev1.Secret{},
|
||||
targetAnnotations: map[string]string{
|
||||
"source-generation": "0",
|
||||
"source-content-hash": mustComputeHash(t, &corev1.Secret{Data: map[string][]byte{"key": []byte("value")}}),
|
||||
},
|
||||
want: false,
|
||||
wantError: false,
|
||||
},
|
||||
{
|
||||
name: "needs sync when content hash changed",
|
||||
source: &corev1.ConfigMap{
|
||||
Data: map[string]string{"key": "newvalue"},
|
||||
},
|
||||
target: &corev1.ConfigMap{},
|
||||
targetAnnotations: map[string]string{
|
||||
"source-content-hash": "oldhash",
|
||||
},
|
||||
want: true,
|
||||
wantError: false,
|
||||
},
|
||||
{
|
||||
name: "needs sync when no previous hash",
|
||||
source: &corev1.Secret{
|
||||
Data: map[string][]byte{"key": []byte("value")},
|
||||
},
|
||||
target: &corev1.Secret{},
|
||||
targetAnnotations: map[string]string{},
|
||||
want: true,
|
||||
wantError: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := NeedsSync(tt.source, tt.target, tt.targetAnnotations)
|
||||
|
||||
if tt.wantError {
|
||||
require.Error(t, err)
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetGeneration(t *testing.T) {
|
||||
tests := []struct {
|
||||
obj runtime.Object
|
||||
name string
|
||||
want int64
|
||||
}{
|
||||
{
|
||||
name: "returns generation for resource with generation",
|
||||
obj: &unstructured.Unstructured{
|
||||
Object: map[string]interface{}{
|
||||
"metadata": map[string]interface{}{
|
||||
"generation": int64(42),
|
||||
},
|
||||
},
|
||||
},
|
||||
want: 42,
|
||||
},
|
||||
{
|
||||
name: "returns 0 for resource without generation",
|
||||
obj: &corev1.Secret{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test",
|
||||
},
|
||||
},
|
||||
want: 0,
|
||||
},
|
||||
{
|
||||
name: "returns 0 for nil metadata",
|
||||
obj: &unstructured.Unstructured{
|
||||
Object: map[string]interface{}{},
|
||||
},
|
||||
want: 0,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := getGeneration(tt.obj)
|
||||
assert.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to compute hash for test setup
|
||||
func mustComputeHash(t *testing.T, obj runtime.Object) string {
|
||||
t.Helper()
|
||||
hash, err := ComputeContentHash(obj)
|
||||
require.NoError(t, err)
|
||||
return hash
|
||||
}
|
||||
|
||||
// Benchmark tests
|
||||
func BenchmarkComputeContentHash_Secret(b *testing.B) {
|
||||
secret := &corev1.Secret{
|
||||
Data: map[string][]byte{
|
||||
"password": []byte("secret123"),
|
||||
"username": []byte("admin"),
|
||||
},
|
||||
Type: corev1.SecretTypeOpaque,
|
||||
}
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_, _ = ComputeContentHash(secret)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkComputeContentHash_ConfigMap(b *testing.B) {
|
||||
cm := &corev1.ConfigMap{
|
||||
Data: map[string]string{
|
||||
"config.yaml": "setting: value\nother: data",
|
||||
},
|
||||
}
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_, _ = ComputeContentHash(cm)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkNeedsSync(b *testing.B) {
|
||||
source := &corev1.Secret{
|
||||
Data: map[string][]byte{"key": []byte("value")},
|
||||
}
|
||||
target := &corev1.Secret{}
|
||||
hash, _ := ComputeContentHash(source)
|
||||
annotations := map[string]string{
|
||||
"source-content-hash": hash,
|
||||
}
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_, _ = NeedsSync(source, target, annotations)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user