feat: e2e tests and a lot of refactoring for existing tests

This commit is contained in:
TheiLLeniumStudios
2025-12-28 08:47:58 +01:00
parent 0c40064872
commit 172517512b
34 changed files with 2687 additions and 2843 deletions
+4 -1
View File
@@ -106,9 +106,12 @@ jobs:
kubectl cluster-info
- name: Test
- name: Unit Tests
run: make test
- name: E2E Tests
run: make e2e
- name: Generate Tags
id: generate_tag
run: |
+4 -1
View File
@@ -147,7 +147,10 @@ manifest:
docker manifest annotate --arch $(ARCH) $(REPOSITORY_GENERIC) $(REPOSITORY_ARCH)
test:
"$(GOCMD)" test -timeout 1800s -v ./cmd/... ./internal/...
"$(GOCMD)" test -timeout 1800s -v -short ./cmd/... ./internal/...
e2e:
"$(GOCMD)" test -timeout 1800s -v ./test/...
stop:
@docker stop "${BINARY}"
+1 -1
View File
@@ -7,6 +7,7 @@ require (
github.com/go-logr/logr v1.4.2
github.com/go-logr/zerologr v1.2.3
github.com/prometheus/client_golang v1.22.0
github.com/prometheus/client_model v0.6.2
github.com/rs/zerolog v1.34.0
github.com/spf13/cobra v1.10.1
github.com/spf13/pflag v1.0.9
@@ -43,7 +44,6 @@ require (
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.63.0 // indirect
github.com/prometheus/procfs v0.16.0 // indirect
github.com/x448/float16 v0.8.4 // indirect
+3 -3
View File
@@ -45,9 +45,9 @@ func testAlertMessage() AlertMessage {
func TestNewAlerter(t *testing.T) {
tests := []struct {
name string
setup func(*config.Config)
wantType string
name string
setup func(*config.Config)
wantType string
}{
{
name: "disabled",
+54 -55
View File
@@ -26,75 +26,75 @@ const (
// Config holds all configuration for Reloader.
type Config struct {
Annotations AnnotationConfig
AutoReloadAll bool
ReloadStrategy ReloadStrategy
ArgoRolloutsEnabled bool
ArgoRolloutStrategy ArgoRolloutStrategy
ReloadOnCreate bool
ReloadOnDelete bool
SyncAfterRestart bool
EnableHA bool
WebhookURL string
Annotations AnnotationConfig `json:"annotations"`
AutoReloadAll bool `json:"autoReloadAll"`
ReloadStrategy ReloadStrategy `json:"reloadStrategy"`
ArgoRolloutsEnabled bool `json:"argoRolloutsEnabled"`
ArgoRolloutStrategy ArgoRolloutStrategy `json:"argoRolloutStrategy"`
ReloadOnCreate bool `json:"reloadOnCreate"`
ReloadOnDelete bool `json:"reloadOnDelete"`
SyncAfterRestart bool `json:"syncAfterRestart"`
EnableHA bool `json:"enableHA"`
WebhookURL string `json:"webhookUrl,omitempty"`
IgnoredResources []string
IgnoredWorkloads []string
IgnoredNamespaces []string
NamespaceSelectors []labels.Selector
ResourceSelectors []labels.Selector
NamespaceSelectorStrings []string
ResourceSelectorStrings []string
IgnoredResources []string `json:"ignoredResources,omitempty"`
IgnoredWorkloads []string `json:"ignoredWorkloads,omitempty"`
IgnoredNamespaces []string `json:"ignoredNamespaces,omitempty"`
NamespaceSelectors []labels.Selector `json:"-"`
ResourceSelectors []labels.Selector `json:"-"`
NamespaceSelectorStrings []string `json:"namespaceSelectors,omitempty"`
ResourceSelectorStrings []string `json:"resourceSelectors,omitempty"`
LogFormat string
LogLevel string
MetricsAddr string
HealthAddr string
EnablePProf bool
PProfAddr string
LogFormat string `json:"logFormat,omitempty"`
LogLevel string `json:"logLevel"`
MetricsAddr string `json:"metricsAddr"`
HealthAddr string `json:"healthAddr"`
EnablePProf bool `json:"enablePProf"`
PProfAddr string `json:"pprofAddr,omitempty"`
Alerting AlertingConfig
LeaderElection LeaderElectionConfig
WatchedNamespace string
SyncPeriod time.Duration
Alerting AlertingConfig `json:"alerting"`
LeaderElection LeaderElectionConfig `json:"leaderElection"`
WatchedNamespace string `json:"watchedNamespace,omitempty"`
SyncPeriod time.Duration `json:"syncPeriod"`
}
// AnnotationConfig holds customizable annotation keys.
type AnnotationConfig struct {
Prefix string
Auto string
ConfigmapAuto string
SecretAuto string
ConfigmapReload string
SecretReload string
ConfigmapExclude string
SecretExclude string
Ignore string
Search string
Match string
RolloutStrategy string
PausePeriod string
PausedAt string
LastReloadedFrom string
Prefix string `json:"prefix"`
Auto string `json:"auto"`
ConfigmapAuto string `json:"configmapAuto"`
SecretAuto string `json:"secretAuto"`
ConfigmapReload string `json:"configmapReload"`
SecretReload string `json:"secretReload"`
ConfigmapExclude string `json:"configmapExclude"`
SecretExclude string `json:"secretExclude"`
Ignore string `json:"ignore"`
Search string `json:"search"`
Match string `json:"match"`
RolloutStrategy string `json:"rolloutStrategy"`
PausePeriod string `json:"pausePeriod"`
PausedAt string `json:"pausedAt"`
LastReloadedFrom string `json:"lastReloadedFrom"`
}
// AlertingConfig holds configuration for alerting integrations.
type AlertingConfig struct {
Enabled bool
WebhookURL string
Sink string
Proxy string
Additional string
Enabled bool `json:"enabled"`
WebhookURL string `json:"webhookUrl,omitempty"`
Sink string `json:"sink,omitempty"`
Proxy string `json:"proxy,omitempty"`
Additional string `json:"additional,omitempty"`
}
// LeaderElectionConfig holds configuration for leader election.
type LeaderElectionConfig struct {
LockName string
Namespace string
Identity string
LeaseDuration time.Duration
RenewDeadline time.Duration
RetryPeriod time.Duration
ReleaseOnCancel bool
LockName string `json:"lockName"`
Namespace string `json:"namespace,omitempty"`
Identity string `json:"identity,omitempty"`
LeaseDuration time.Duration `json:"leaseDuration"`
RenewDeadline time.Duration `json:"renewDeadline"`
RetryPeriod time.Duration `json:"retryPeriod"`
ReleaseOnCancel bool `json:"releaseOnCancel"`
}
// NewDefault creates a Config with default values.
@@ -184,4 +184,3 @@ func (c *Config) IsNamespaceIgnored(namespace string) bool {
}
return false
}
-2
View File
@@ -12,7 +12,6 @@ func TestNewDefault(t *testing.T) {
t.Fatal("NewDefault() returned nil")
}
// Test default values
if cfg.ReloadStrategy != ReloadStrategyEnvVars {
t.Errorf("ReloadStrategy = %v, want %v", cfg.ReloadStrategy, ReloadStrategyEnvVars)
}
@@ -200,4 +199,3 @@ func TestConfig_IsNamespaceIgnored(t *testing.T) {
)
}
}
+1 -1
View File
@@ -208,7 +208,7 @@ func ApplyFlags(cfg *Config) error {
cfg.IgnoredWorkloads = splitAndTrim(fv.ignoredWorkloads)
cfg.IgnoredNamespaces = splitAndTrim(fv.ignoredNamespaces)
// Store raw selector strings (for backward compatibility)
// Store raw selector strings
cfg.NamespaceSelectorStrings = splitAndTrim(fv.namespaceSelectors)
cfg.ResourceSelectorStrings = splitAndTrim(fv.resourceSelectors)
+40 -44
View File
@@ -12,7 +12,6 @@ func TestBindFlags(t *testing.T) {
BindFlags(fs, cfg)
// Verify flags are registered
expectedFlags := []string{
"auto-reload-all",
"reload-strategy",
@@ -64,12 +63,10 @@ func TestBindFlags_DefaultValues(t *testing.T) {
BindFlags(fs, cfg)
// Parse empty args to use defaults
if err := fs.Parse([]string{}); err != nil {
t.Fatalf("Parse() error = %v", err)
}
// Check default values are preserved
if cfg.ReloadStrategy != ReloadStrategyEnvVars {
t.Errorf("ReloadStrategy = %v, want %v", cfg.ReloadStrategy, ReloadStrategyEnvVars)
}
@@ -146,33 +143,33 @@ func TestApplyFlags_BooleanStrings(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Reset flag values
fv = flagValues{}
t.Run(
tt.name, func(t *testing.T) {
fv = flagValues{}
cfg := NewDefault()
fs := pflag.NewFlagSet("test", pflag.ContinueOnError)
BindFlags(fs, cfg)
cfg := NewDefault()
fs := pflag.NewFlagSet("test", pflag.ContinueOnError)
BindFlags(fs, cfg)
if err := fs.Parse(tt.args); err != nil {
t.Fatalf("Parse() error = %v", err)
}
if err := fs.Parse(tt.args); err != nil {
t.Fatalf("Parse() error = %v", err)
}
err := ApplyFlags(cfg)
if (err != nil) != tt.wantErr {
t.Errorf("ApplyFlags() error = %v, wantErr %v", err, tt.wantErr)
return
}
err := ApplyFlags(cfg)
if (err != nil) != tt.wantErr {
t.Errorf("ApplyFlags() error = %v, wantErr %v", err, tt.wantErr)
return
}
if cfg.ArgoRolloutsEnabled != tt.want {
t.Errorf("ArgoRolloutsEnabled = %v, want %v", cfg.ArgoRolloutsEnabled, tt.want)
}
})
if cfg.ArgoRolloutsEnabled != tt.want {
t.Errorf("ArgoRolloutsEnabled = %v, want %v", cfg.ArgoRolloutsEnabled, tt.want)
}
},
)
}
}
func TestApplyFlags_CommaSeparatedLists(t *testing.T) {
// Reset flag values
fv = flagValues{}
cfg := NewDefault()
@@ -193,7 +190,6 @@ func TestApplyFlags_CommaSeparatedLists(t *testing.T) {
t.Fatalf("ApplyFlags() error = %v", err)
}
// Check ignored resources
if len(cfg.IgnoredResources) != 2 {
t.Errorf("IgnoredResources length = %d, want 2", len(cfg.IgnoredResources))
}
@@ -201,7 +197,6 @@ func TestApplyFlags_CommaSeparatedLists(t *testing.T) {
t.Errorf("IgnoredResources = %v", cfg.IgnoredResources)
}
// Check ignored workloads
if len(cfg.IgnoredWorkloads) != 2 {
t.Errorf("IgnoredWorkloads length = %d, want 2", len(cfg.IgnoredWorkloads))
}
@@ -213,7 +208,6 @@ func TestApplyFlags_CommaSeparatedLists(t *testing.T) {
}
func TestApplyFlags_Selectors(t *testing.T) {
// Reset flag values
fv = flagValues{}
cfg := NewDefault()
@@ -241,14 +235,12 @@ func TestApplyFlags_Selectors(t *testing.T) {
t.Errorf("ResourceSelectors length = %d, want 1", len(cfg.ResourceSelectors))
}
// Check string versions are preserved
if len(cfg.NamespaceSelectorStrings) != 2 {
t.Errorf("NamespaceSelectorStrings length = %d, want 2", len(cfg.NamespaceSelectorStrings))
}
}
func TestApplyFlags_InvalidSelector(t *testing.T) {
// Reset flag values
fv = flagValues{}
cfg := NewDefault()
@@ -290,12 +282,14 @@ func TestParseBoolString(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
got := parseBoolString(tt.input)
if got != tt.want {
t.Errorf("parseBoolString(%q) = %v, want %v", tt.input, got, tt.want)
}
})
t.Run(
tt.input, func(t *testing.T) {
got := parseBoolString(tt.input)
if got != tt.want {
t.Errorf("parseBoolString(%q) = %v, want %v", tt.input, got, tt.want)
}
},
)
}
}
@@ -314,17 +308,19 @@ func TestSplitAndTrim(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := splitAndTrim(tt.input)
if len(got) != len(tt.want) {
t.Errorf("splitAndTrim(%q) length = %d, want %d", tt.input, len(got), len(tt.want))
return
}
for i := range got {
if got[i] != tt.want[i] {
t.Errorf("splitAndTrim(%q)[%d] = %q, want %q", tt.input, i, got[i], tt.want[i])
t.Run(
tt.name, func(t *testing.T) {
got := splitAndTrim(tt.input)
if len(got) != len(tt.want) {
t.Errorf("splitAndTrim(%q) length = %d, want %d", tt.input, len(got), len(tt.want))
return
}
}
})
for i := range got {
if got[i] != tt.want[i] {
t.Errorf("splitAndTrim(%q)[%d] = %q, want %q", tt.input, i, got[i], tt.want[i])
}
}
},
)
}
}
+1 -1
View File
@@ -66,7 +66,7 @@ func (c *Config) Validate() error {
default:
errs = append(
errs, ValidationError{
Field: "ArgoRolloutStrategy",
Field: "ArgoRolloutStrategy",
Message: fmt.Sprintf(
"invalid value %q, must be %q or %q", c.ArgoRolloutStrategy, ArgoRolloutStrategyRestart, ArgoRolloutStrategyRollout,
),
-7
View File
@@ -1,7 +0,0 @@
package constants
// Environment variable names for pod identity in HA mode.
const (
PodNameEnv string = "POD_NAME"
PodNamespaceEnv string = "POD_NAMESPACE"
)
@@ -1,844 +1,159 @@
package controller_test
import (
"context"
"testing"
"github.com/go-logr/logr/testr"
"github.com/stakater/Reloader/internal/pkg/alerting"
"github.com/stakater/Reloader/internal/pkg/config"
"github.com/stakater/Reloader/internal/pkg/controller"
"github.com/stakater/Reloader/internal/pkg/events"
"github.com/stakater/Reloader/internal/pkg/metrics"
"github.com/stakater/Reloader/internal/pkg/reload"
"github.com/stakater/Reloader/internal/pkg/webhook"
"github.com/stakater/Reloader/internal/pkg/workload"
appsv1 "k8s.io/api/apps/v1"
batchv1 "k8s.io/api/batch/v1"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/types"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client/fake"
)
func newTestScheme() *runtime.Scheme {
scheme := runtime.NewScheme()
_ = corev1.AddToScheme(scheme)
_ = appsv1.AddToScheme(scheme)
_ = batchv1.AddToScheme(scheme)
return scheme
}
func newTestConfigMapReconciler(t *testing.T, cfg *config.Config, objects ...runtime.Object) *controller.ConfigMapReconciler {
scheme := newTestScheme()
fakeClient := fake.NewClientBuilder().
WithScheme(scheme).
WithRuntimeObjects(objects...).
Build()
collectors := metrics.NewCollectors()
return &controller.ConfigMapReconciler{
Client: fakeClient,
Log: testr.New(t),
Config: cfg,
ReloadService: reload.NewService(cfg),
Registry: workload.NewRegistry(cfg.ArgoRolloutsEnabled),
Collectors: &collectors,
EventRecorder: events.NewRecorder(nil),
WebhookClient: webhook.NewClient("", testr.New(t)),
Alerter: &alerting.NoOpAlerter{},
}
}
func TestConfigMapReconciler_NotFound(t *testing.T) {
cfg := config.NewDefault()
reconciler := newTestConfigMapReconciler(t, cfg)
req := ctrl.Request{
NamespacedName: types.NamespacedName{
Name: "nonexistent-cm",
Namespace: "default",
},
}
result, err := reconciler.Reconcile(context.Background(), req)
if err != nil {
t.Fatalf("Reconcile failed: %v", err)
}
if result.Requeue {
t.Error("Should not requeue for NotFound")
}
reconciler := newConfigMapReconciler(t, cfg)
assertReconcileSuccess(t, reconciler, reconcileRequest("nonexistent-cm", "default"))
}
func TestConfigMapReconciler_NotFound_ReloadOnDelete(t *testing.T) {
cfg := config.NewDefault()
cfg.ReloadOnDelete = true
deployment := &appsv1.Deployment{
ObjectMeta: metav1.ObjectMeta{
Name: "test-deployment",
Namespace: "default",
Annotations: map[string]string{
cfg.Annotations.ConfigmapReload: "deleted-cm",
},
},
Spec: appsv1.DeploymentSpec{
Selector: &metav1.LabelSelector{
MatchLabels: map[string]string{"app": "test"},
},
Template: corev1.PodTemplateSpec{
ObjectMeta: metav1.ObjectMeta{
Labels: map[string]string{"app": "test"},
},
Spec: corev1.PodSpec{
Containers: []corev1.Container{{
Name: "main",
Image: "nginx",
}},
},
},
},
}
reconciler := newTestConfigMapReconciler(t, cfg, deployment)
req := ctrl.Request{
NamespacedName: types.NamespacedName{
Name: "deleted-cm",
Namespace: "default",
},
}
result, err := reconciler.Reconcile(context.Background(), req)
if err != nil {
t.Fatalf("Reconcile failed: %v", err)
}
if result.Requeue {
t.Error("Should not requeue")
}
deployment := testDeployment("test-deployment", "default", map[string]string{
cfg.Annotations.ConfigmapReload: "deleted-cm",
})
reconciler := newConfigMapReconciler(t, cfg, deployment)
assertReconcileSuccess(t, reconciler, reconcileRequest("deleted-cm", "default"))
}
func TestConfigMapReconciler_IgnoredNamespace(t *testing.T) {
cfg := config.NewDefault()
cfg.IgnoredNamespaces = []string{"kube-system"}
cm := &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{
Name: "test-cm",
Namespace: "kube-system",
},
Data: map[string]string{"key": "value"},
}
reconciler := newTestConfigMapReconciler(t, cfg, cm)
req := ctrl.Request{
NamespacedName: types.NamespacedName{
Name: "test-cm",
Namespace: "kube-system",
},
}
result, err := reconciler.Reconcile(context.Background(), req)
if err != nil {
t.Fatalf("Reconcile failed: %v", err)
}
if result.Requeue {
t.Error("Should not requeue for ignored namespace")
}
cm := testConfigMap("test-cm", "kube-system")
reconciler := newConfigMapReconciler(t, cfg, cm)
assertReconcileSuccess(t, reconciler, reconcileRequest("test-cm", "kube-system"))
}
func TestConfigMapReconciler_NoMatchingWorkloads(t *testing.T) {
cfg := config.NewDefault()
cm := &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{
Name: "test-cm",
Namespace: "default",
},
Data: map[string]string{"key": "value"},
}
deployment := &appsv1.Deployment{
ObjectMeta: metav1.ObjectMeta{
Name: "test-deployment",
Namespace: "default",
},
Spec: appsv1.DeploymentSpec{
Selector: &metav1.LabelSelector{
MatchLabels: map[string]string{"app": "test"},
},
Template: corev1.PodTemplateSpec{
ObjectMeta: metav1.ObjectMeta{
Labels: map[string]string{"app": "test"},
},
Spec: corev1.PodSpec{
Containers: []corev1.Container{{
Name: "main",
Image: "nginx",
}},
},
},
},
}
reconciler := newTestConfigMapReconciler(t, cfg, cm, deployment)
req := ctrl.Request{
NamespacedName: types.NamespacedName{
Name: "test-cm",
Namespace: "default",
},
}
result, err := reconciler.Reconcile(context.Background(), req)
if err != nil {
t.Fatalf("Reconcile failed: %v", err)
}
if result.Requeue {
t.Error("Should not requeue")
}
cm := testConfigMap("test-cm", "default")
deployment := testDeployment("test-deployment", "default", nil)
reconciler := newConfigMapReconciler(t, cfg, cm, deployment)
assertReconcileSuccess(t, reconciler, reconcileRequest("test-cm", "default"))
}
func TestConfigMapReconciler_MatchingDeployment_AutoAnnotation(t *testing.T) {
cfg := config.NewDefault()
cfg.AutoReloadAll = true
cm := &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{
Name: "test-cm",
Namespace: "default",
},
Data: map[string]string{"key": "value"},
}
deployment := &appsv1.Deployment{
ObjectMeta: metav1.ObjectMeta{
Name: "test-deployment",
Namespace: "default",
},
Spec: appsv1.DeploymentSpec{
Selector: &metav1.LabelSelector{
MatchLabels: map[string]string{"app": "test"},
},
Template: corev1.PodTemplateSpec{
ObjectMeta: metav1.ObjectMeta{
Labels: map[string]string{"app": "test"},
},
Spec: corev1.PodSpec{
Containers: []corev1.Container{{
Name: "main",
Image: "nginx",
EnvFrom: []corev1.EnvFromSource{{
ConfigMapRef: &corev1.ConfigMapEnvSource{
LocalObjectReference: corev1.LocalObjectReference{
Name: "test-cm",
},
},
}},
}},
},
},
},
}
reconciler := newTestConfigMapReconciler(t, cfg, cm, deployment)
req := ctrl.Request{
NamespacedName: types.NamespacedName{
Name: "test-cm",
Namespace: "default",
},
}
result, err := reconciler.Reconcile(context.Background(), req)
if err != nil {
t.Fatalf("Reconcile failed: %v", err)
}
if result.Requeue {
t.Error("Should not requeue")
}
cm := testConfigMap("test-cm", "default")
deployment := testDeploymentWithEnvFrom("test-deployment", "default", "test-cm", "")
reconciler := newConfigMapReconciler(t, cfg, cm, deployment)
assertReconcileSuccess(t, reconciler, reconcileRequest("test-cm", "default"))
}
func TestConfigMapReconciler_MatchingDeployment_ExplicitAnnotation(t *testing.T) {
cfg := config.NewDefault()
cm := &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{
Name: "test-cm",
Namespace: "default",
},
Data: map[string]string{"key": "value"},
}
deployment := &appsv1.Deployment{
ObjectMeta: metav1.ObjectMeta{
Name: "test-deployment",
Namespace: "default",
Annotations: map[string]string{
cfg.Annotations.ConfigmapReload: "test-cm",
},
},
Spec: appsv1.DeploymentSpec{
Selector: &metav1.LabelSelector{
MatchLabels: map[string]string{"app": "test"},
},
Template: corev1.PodTemplateSpec{
ObjectMeta: metav1.ObjectMeta{
Labels: map[string]string{"app": "test"},
},
Spec: corev1.PodSpec{
Containers: []corev1.Container{{
Name: "main",
Image: "nginx",
}},
},
},
},
}
reconciler := newTestConfigMapReconciler(t, cfg, cm, deployment)
req := ctrl.Request{
NamespacedName: types.NamespacedName{
Name: "test-cm",
Namespace: "default",
},
}
result, err := reconciler.Reconcile(context.Background(), req)
if err != nil {
t.Fatalf("Reconcile failed: %v", err)
}
if result.Requeue {
t.Error("Should not requeue")
}
cm := testConfigMap("test-cm", "default")
deployment := testDeployment("test-deployment", "default", map[string]string{
cfg.Annotations.ConfigmapReload: "test-cm",
})
reconciler := newConfigMapReconciler(t, cfg, cm, deployment)
assertReconcileSuccess(t, reconciler, reconcileRequest("test-cm", "default"))
}
func TestConfigMapReconciler_WorkloadInDifferentNamespace(t *testing.T) {
cfg := config.NewDefault()
cm := &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{
Name: "test-cm",
Namespace: "namespace-a",
},
Data: map[string]string{"key": "value"},
}
deployment := &appsv1.Deployment{
ObjectMeta: metav1.ObjectMeta{
Name: "test-deployment",
Namespace: "namespace-b",
Annotations: map[string]string{
cfg.Annotations.ConfigmapReload: "test-cm",
},
},
Spec: appsv1.DeploymentSpec{
Selector: &metav1.LabelSelector{
MatchLabels: map[string]string{"app": "test"},
},
Template: corev1.PodTemplateSpec{
ObjectMeta: metav1.ObjectMeta{
Labels: map[string]string{"app": "test"},
},
Spec: corev1.PodSpec{
Containers: []corev1.Container{{
Name: "main",
Image: "nginx",
}},
},
},
},
}
reconciler := newTestConfigMapReconciler(t, cfg, cm, deployment)
req := ctrl.Request{
NamespacedName: types.NamespacedName{
Name: "test-cm",
Namespace: "namespace-a",
},
}
result, err := reconciler.Reconcile(context.Background(), req)
if err != nil {
t.Fatalf("Reconcile failed: %v", err)
}
if result.Requeue {
t.Error("Should not requeue")
}
cm := testConfigMap("test-cm", "namespace-a")
deployment := testDeployment("test-deployment", "namespace-b", map[string]string{
cfg.Annotations.ConfigmapReload: "test-cm",
})
reconciler := newConfigMapReconciler(t, cfg, cm, deployment)
assertReconcileSuccess(t, reconciler, reconcileRequest("test-cm", "namespace-a"))
}
func TestConfigMapReconciler_IgnoredWorkloadType(t *testing.T) {
cfg := config.NewDefault()
cfg.IgnoredWorkloads = []string{"deployment"}
cm := &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{
Name: "test-cm",
Namespace: "default",
},
Data: map[string]string{"key": "value"},
}
deployment := &appsv1.Deployment{
ObjectMeta: metav1.ObjectMeta{
Name: "test-deployment",
Namespace: "default",
Annotations: map[string]string{
cfg.Annotations.ConfigmapReload: "test-cm",
},
},
Spec: appsv1.DeploymentSpec{
Selector: &metav1.LabelSelector{
MatchLabels: map[string]string{"app": "test"},
},
Template: corev1.PodTemplateSpec{
ObjectMeta: metav1.ObjectMeta{
Labels: map[string]string{"app": "test"},
},
Spec: corev1.PodSpec{
Containers: []corev1.Container{{
Name: "main",
Image: "nginx",
}},
},
},
},
}
reconciler := newTestConfigMapReconciler(t, cfg, cm, deployment)
req := ctrl.Request{
NamespacedName: types.NamespacedName{
Name: "test-cm",
Namespace: "default",
},
}
result, err := reconciler.Reconcile(context.Background(), req)
if err != nil {
t.Fatalf("Reconcile failed: %v", err)
}
if result.Requeue {
t.Error("Should not requeue")
}
cm := testConfigMap("test-cm", "default")
deployment := testDeployment("test-deployment", "default", map[string]string{
cfg.Annotations.ConfigmapReload: "test-cm",
})
reconciler := newConfigMapReconciler(t, cfg, cm, deployment)
assertReconcileSuccess(t, reconciler, reconcileRequest("test-cm", "default"))
}
func TestConfigMapReconciler_DaemonSet(t *testing.T) {
cfg := config.NewDefault()
cm := &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{
Name: "test-cm",
Namespace: "default",
},
Data: map[string]string{"key": "value"},
}
daemonset := &appsv1.DaemonSet{
ObjectMeta: metav1.ObjectMeta{
Name: "test-daemonset",
Namespace: "default",
Annotations: map[string]string{
cfg.Annotations.ConfigmapReload: "test-cm",
},
},
Spec: appsv1.DaemonSetSpec{
Selector: &metav1.LabelSelector{
MatchLabels: map[string]string{"app": "test"},
},
Template: corev1.PodTemplateSpec{
ObjectMeta: metav1.ObjectMeta{
Labels: map[string]string{"app": "test"},
},
Spec: corev1.PodSpec{
Containers: []corev1.Container{{
Name: "main",
Image: "nginx",
}},
},
},
},
}
reconciler := newTestConfigMapReconciler(t, cfg, cm, daemonset)
req := ctrl.Request{
NamespacedName: types.NamespacedName{
Name: "test-cm",
Namespace: "default",
},
}
result, err := reconciler.Reconcile(context.Background(), req)
if err != nil {
t.Fatalf("Reconcile failed: %v", err)
}
if result.Requeue {
t.Error("Should not requeue")
}
cm := testConfigMap("test-cm", "default")
daemonset := testDaemonSet("test-daemonset", "default", map[string]string{
cfg.Annotations.ConfigmapReload: "test-cm",
})
reconciler := newConfigMapReconciler(t, cfg, cm, daemonset)
assertReconcileSuccess(t, reconciler, reconcileRequest("test-cm", "default"))
}
func TestConfigMapReconciler_StatefulSet(t *testing.T) {
cfg := config.NewDefault()
cm := &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{
Name: "test-cm",
Namespace: "default",
},
Data: map[string]string{"key": "value"},
}
statefulset := &appsv1.StatefulSet{
ObjectMeta: metav1.ObjectMeta{
Name: "test-statefulset",
Namespace: "default",
Annotations: map[string]string{
cfg.Annotations.ConfigmapReload: "test-cm",
},
},
Spec: appsv1.StatefulSetSpec{
Selector: &metav1.LabelSelector{
MatchLabels: map[string]string{"app": "test"},
},
Template: corev1.PodTemplateSpec{
ObjectMeta: metav1.ObjectMeta{
Labels: map[string]string{"app": "test"},
},
Spec: corev1.PodSpec{
Containers: []corev1.Container{{
Name: "main",
Image: "nginx",
}},
},
},
},
}
reconciler := newTestConfigMapReconciler(t, cfg, cm, statefulset)
req := ctrl.Request{
NamespacedName: types.NamespacedName{
Name: "test-cm",
Namespace: "default",
},
}
result, err := reconciler.Reconcile(context.Background(), req)
if err != nil {
t.Fatalf("Reconcile failed: %v", err)
}
if result.Requeue {
t.Error("Should not requeue")
}
cm := testConfigMap("test-cm", "default")
statefulset := testStatefulSet("test-statefulset", "default", map[string]string{
cfg.Annotations.ConfigmapReload: "test-cm",
})
reconciler := newConfigMapReconciler(t, cfg, cm, statefulset)
assertReconcileSuccess(t, reconciler, reconcileRequest("test-cm", "default"))
}
func TestConfigMapReconciler_MultipleWorkloads(t *testing.T) {
cfg := config.NewDefault()
cm := &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{
Name: "shared-cm",
Namespace: "default",
},
Data: map[string]string{"key": "value"},
}
cm := testConfigMap("shared-cm", "default")
deployment1 := testDeployment("deployment-1", "default", map[string]string{
cfg.Annotations.ConfigmapReload: "shared-cm",
})
deployment2 := testDeployment("deployment-2", "default", map[string]string{
cfg.Annotations.ConfigmapReload: "shared-cm",
})
daemonset := testDaemonSet("daemonset-1", "default", map[string]string{
cfg.Annotations.ConfigmapReload: "shared-cm",
})
deployment1 := &appsv1.Deployment{
ObjectMeta: metav1.ObjectMeta{
Name: "deployment-1",
Namespace: "default",
Annotations: map[string]string{
cfg.Annotations.ConfigmapReload: "shared-cm",
},
},
Spec: appsv1.DeploymentSpec{
Selector: &metav1.LabelSelector{
MatchLabels: map[string]string{"app": "test1"},
},
Template: corev1.PodTemplateSpec{
ObjectMeta: metav1.ObjectMeta{
Labels: map[string]string{"app": "test1"},
},
Spec: corev1.PodSpec{
Containers: []corev1.Container{{
Name: "main",
Image: "nginx",
}},
},
},
},
}
deployment2 := &appsv1.Deployment{
ObjectMeta: metav1.ObjectMeta{
Name: "deployment-2",
Namespace: "default",
Annotations: map[string]string{
cfg.Annotations.ConfigmapReload: "shared-cm",
},
},
Spec: appsv1.DeploymentSpec{
Selector: &metav1.LabelSelector{
MatchLabels: map[string]string{"app": "test2"},
},
Template: corev1.PodTemplateSpec{
ObjectMeta: metav1.ObjectMeta{
Labels: map[string]string{"app": "test2"},
},
Spec: corev1.PodSpec{
Containers: []corev1.Container{{
Name: "main",
Image: "nginx",
}},
},
},
},
}
daemonset := &appsv1.DaemonSet{
ObjectMeta: metav1.ObjectMeta{
Name: "daemonset-1",
Namespace: "default",
Annotations: map[string]string{
cfg.Annotations.ConfigmapReload: "shared-cm",
},
},
Spec: appsv1.DaemonSetSpec{
Selector: &metav1.LabelSelector{
MatchLabels: map[string]string{"app": "daemon"},
},
Template: corev1.PodTemplateSpec{
ObjectMeta: metav1.ObjectMeta{
Labels: map[string]string{"app": "daemon"},
},
Spec: corev1.PodSpec{
Containers: []corev1.Container{{
Name: "main",
Image: "nginx",
}},
},
},
},
}
reconciler := newTestConfigMapReconciler(t, cfg, cm, deployment1, deployment2, daemonset)
req := ctrl.Request{
NamespacedName: types.NamespacedName{
Name: "shared-cm",
Namespace: "default",
},
}
result, err := reconciler.Reconcile(context.Background(), req)
if err != nil {
t.Fatalf("Reconcile failed: %v", err)
}
if result.Requeue {
t.Error("Should not requeue")
}
reconciler := newConfigMapReconciler(t, cfg, cm, deployment1, deployment2, daemonset)
assertReconcileSuccess(t, reconciler, reconcileRequest("shared-cm", "default"))
}
func TestConfigMapReconciler_VolumeMount(t *testing.T) {
cfg := config.NewDefault()
cfg.AutoReloadAll = true
cm := &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{
Name: "volume-cm",
Namespace: "default",
},
Data: map[string]string{"config.yaml": "key: value"},
}
deployment := &appsv1.Deployment{
ObjectMeta: metav1.ObjectMeta{
Name: "test-deployment",
Namespace: "default",
},
Spec: appsv1.DeploymentSpec{
Selector: &metav1.LabelSelector{
MatchLabels: map[string]string{"app": "test"},
},
Template: corev1.PodTemplateSpec{
ObjectMeta: metav1.ObjectMeta{
Labels: map[string]string{"app": "test"},
},
Spec: corev1.PodSpec{
Containers: []corev1.Container{{
Name: "main",
Image: "nginx",
VolumeMounts: []corev1.VolumeMount{{
Name: "config",
MountPath: "/etc/config",
}},
}},
Volumes: []corev1.Volume{{
Name: "config",
VolumeSource: corev1.VolumeSource{
ConfigMap: &corev1.ConfigMapVolumeSource{
LocalObjectReference: corev1.LocalObjectReference{
Name: "volume-cm",
},
},
},
}},
},
},
},
}
reconciler := newTestConfigMapReconciler(t, cfg, cm, deployment)
req := ctrl.Request{
NamespacedName: types.NamespacedName{
Name: "volume-cm",
Namespace: "default",
},
}
result, err := reconciler.Reconcile(context.Background(), req)
if err != nil {
t.Fatalf("Reconcile failed: %v", err)
}
if result.Requeue {
t.Error("Should not requeue")
}
cm := testConfigMap("volume-cm", "default")
deployment := testDeploymentWithVolume("test-deployment", "default", "volume-cm", "")
reconciler := newConfigMapReconciler(t, cfg, cm, deployment)
assertReconcileSuccess(t, reconciler, reconcileRequest("volume-cm", "default"))
}
func TestConfigMapReconciler_ProjectedVolume(t *testing.T) {
cfg := config.NewDefault()
cfg.AutoReloadAll = true
cm := &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{
Name: "projected-cm",
Namespace: "default",
},
Data: map[string]string{"config.yaml": "key: value"},
}
deployment := &appsv1.Deployment{
ObjectMeta: metav1.ObjectMeta{
Name: "test-deployment",
Namespace: "default",
},
Spec: appsv1.DeploymentSpec{
Selector: &metav1.LabelSelector{
MatchLabels: map[string]string{"app": "test"},
},
Template: corev1.PodTemplateSpec{
ObjectMeta: metav1.ObjectMeta{
Labels: map[string]string{"app": "test"},
},
Spec: corev1.PodSpec{
Containers: []corev1.Container{{
Name: "main",
Image: "nginx",
VolumeMounts: []corev1.VolumeMount{{
Name: "config",
MountPath: "/etc/config",
}},
}},
Volumes: []corev1.Volume{{
Name: "config",
VolumeSource: corev1.VolumeSource{
Projected: &corev1.ProjectedVolumeSource{
Sources: []corev1.VolumeProjection{{
ConfigMap: &corev1.ConfigMapProjection{
LocalObjectReference: corev1.LocalObjectReference{
Name: "projected-cm",
},
},
}},
},
},
}},
},
},
},
}
reconciler := newTestConfigMapReconciler(t, cfg, cm, deployment)
req := ctrl.Request{
NamespacedName: types.NamespacedName{
Name: "projected-cm",
Namespace: "default",
},
}
result, err := reconciler.Reconcile(context.Background(), req)
if err != nil {
t.Fatalf("Reconcile failed: %v", err)
}
if result.Requeue {
t.Error("Should not requeue")
}
cm := testConfigMap("projected-cm", "default")
deployment := testDeploymentWithProjectedVolume("test-deployment", "default", "projected-cm", "")
reconciler := newConfigMapReconciler(t, cfg, cm, deployment)
assertReconcileSuccess(t, reconciler, reconcileRequest("projected-cm", "default"))
}
func TestConfigMapReconciler_SearchAnnotation(t *testing.T) {
cfg := config.NewDefault()
cm := &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{
Name: "test-cm",
Namespace: "default",
Annotations: map[string]string{
cfg.Annotations.Match: "true",
},
},
Data: map[string]string{"key": "value"},
}
deployment := &appsv1.Deployment{
ObjectMeta: metav1.ObjectMeta{
Name: "test-deployment",
Namespace: "default",
Annotations: map[string]string{
cfg.Annotations.Search: "true",
},
},
Spec: appsv1.DeploymentSpec{
Selector: &metav1.LabelSelector{
MatchLabels: map[string]string{"app": "test"},
},
Template: corev1.PodTemplateSpec{
ObjectMeta: metav1.ObjectMeta{
Labels: map[string]string{"app": "test"},
},
Spec: corev1.PodSpec{
Containers: []corev1.Container{{
Name: "main",
Image: "nginx",
}},
},
},
},
}
reconciler := newTestConfigMapReconciler(t, cfg, cm, deployment)
req := ctrl.Request{
NamespacedName: types.NamespacedName{
Name: "test-cm",
Namespace: "default",
},
}
result, err := reconciler.Reconcile(context.Background(), req)
if err != nil {
t.Fatalf("Reconcile failed: %v", err)
}
if result.Requeue {
t.Error("Should not requeue")
}
cm := testConfigMapWithAnnotations("test-cm", "default", map[string]string{
cfg.Annotations.Match: "true",
})
deployment := testDeployment("test-deployment", "default", map[string]string{
cfg.Annotations.Search: "true",
})
reconciler := newConfigMapReconciler(t, cfg, cm, deployment)
assertReconcileSuccess(t, reconciler, reconcileRequest("test-cm", "default"))
}
+65 -67
View File
@@ -11,70 +11,72 @@ import (
func TestCreateEventPredicate_CreateEvent(t *testing.T) {
tests := []struct {
name string
reloadOnCreate bool
syncAfterRestart bool
initialized bool
expectedResult bool
name string
reloadOnCreate bool
syncAfterRestart bool
initialized bool
expectedResult bool
}{
{
name: "reload on create enabled, initialized",
reloadOnCreate: true,
syncAfterRestart: false,
initialized: true,
expectedResult: true,
name: "reload on create enabled, initialized",
reloadOnCreate: true,
syncAfterRestart: false,
initialized: true,
expectedResult: true,
},
{
name: "reload on create disabled, initialized",
reloadOnCreate: false,
syncAfterRestart: false,
initialized: true,
expectedResult: false,
name: "reload on create disabled, initialized",
reloadOnCreate: false,
syncAfterRestart: false,
initialized: true,
expectedResult: false,
},
{
name: "not initialized, sync after restart enabled",
reloadOnCreate: true,
syncAfterRestart: true,
initialized: false,
expectedResult: true,
name: "not initialized, sync after restart enabled",
reloadOnCreate: true,
syncAfterRestart: true,
initialized: false,
expectedResult: true,
},
{
name: "not initialized, sync after restart disabled",
reloadOnCreate: true,
syncAfterRestart: false,
initialized: false,
expectedResult: false,
name: "not initialized, sync after restart disabled",
reloadOnCreate: true,
syncAfterRestart: false,
initialized: false,
expectedResult: false,
},
{
name: "not initialized, sync after restart disabled, reload on create disabled",
reloadOnCreate: false,
syncAfterRestart: false,
initialized: false,
expectedResult: false,
name: "not initialized, sync after restart disabled, reload on create disabled",
reloadOnCreate: false,
syncAfterRestart: false,
initialized: false,
expectedResult: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := &config.Config{
ReloadOnCreate: tt.reloadOnCreate,
SyncAfterRestart: tt.syncAfterRestart,
}
initialized := tt.initialized
t.Run(
tt.name, func(t *testing.T) {
cfg := &config.Config{
ReloadOnCreate: tt.reloadOnCreate,
SyncAfterRestart: tt.syncAfterRestart,
}
initialized := tt.initialized
pred := createEventPredicate(cfg, &initialized)
pred := createEventPredicate(cfg, &initialized)
cm := &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"},
}
cm := &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"},
}
e := event.CreateEvent{Object: cm}
result := pred.Create(e)
e := event.CreateEvent{Object: cm}
result := pred.Create(e)
if result != tt.expectedResult {
t.Errorf("CreateFunc() = %v, want %v", result, tt.expectedResult)
}
})
if result != tt.expectedResult {
t.Errorf("CreateFunc() = %v, want %v", result, tt.expectedResult)
}
},
)
}
}
@@ -91,7 +93,6 @@ func TestCreateEventPredicate_UpdateEvent(t *testing.T) {
e := event.UpdateEvent{ObjectOld: cm, ObjectNew: cm}
result := pred.Update(e)
// Update events should always return true
if !result {
t.Error("UpdateFunc() should always return true")
}
@@ -116,25 +117,27 @@ func TestCreateEventPredicate_DeleteEvent(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := &config.Config{
ReloadOnDelete: tt.reloadOnDelete,
}
initialized := true
t.Run(
tt.name, func(t *testing.T) {
cfg := &config.Config{
ReloadOnDelete: tt.reloadOnDelete,
}
initialized := true
pred := createEventPredicate(cfg, &initialized)
pred := createEventPredicate(cfg, &initialized)
cm := &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"},
}
cm := &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"},
}
e := event.DeleteEvent{Object: cm}
result := pred.Delete(e)
e := event.DeleteEvent{Object: cm}
result := pred.Delete(e)
if result != tt.expectedResult {
t.Errorf("DeleteFunc() = %v, want %v", result, tt.expectedResult)
}
})
if result != tt.expectedResult {
t.Errorf("DeleteFunc() = %v, want %v", result, tt.expectedResult)
}
},
)
}
}
@@ -151,7 +154,6 @@ func TestCreateEventPredicate_GenericEvent(t *testing.T) {
e := event.GenericEvent{Object: cm}
result := pred.Generic(e)
// Generic events should always return false
if result {
t.Error("GenericFunc() should always return false")
}
@@ -164,17 +166,14 @@ func TestBuildEventFilter(t *testing.T) {
}
initialized := true
// Create a simple always-true predicate as the resource predicate
resourcePred := &alwaysTruePredicate{}
filter := BuildEventFilter(resourcePred, cfg, &initialized)
// The filter should be created without error
if filter == nil {
t.Fatal("BuildEventFilter() should return a non-nil predicate")
}
// Test update event passes (since resourcePred returns true and update always returns true)
cm := &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"},
}
@@ -182,7 +181,6 @@ func TestBuildEventFilter(t *testing.T) {
e := event.UpdateEvent{ObjectOld: cm, ObjectNew: cm}
result := filter.Update(e)
// Since namespace filter is empty (all namespaces allowed), this should pass
if !result {
t.Error("UpdateFunc() should return true when all predicates pass")
}
+32
View File
@@ -16,6 +16,7 @@ import (
"k8s.io/apimachinery/pkg/runtime"
utilruntime "k8s.io/apimachinery/pkg/util/runtime"
clientgoscheme "k8s.io/client-go/kubernetes/scheme"
"k8s.io/client-go/rest"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/healthz"
ctrlmetrics "sigs.k8s.io/controller-runtime/pkg/metrics/server"
@@ -81,6 +82,37 @@ func NewManager(opts ManagerOptions) (ctrl.Manager, error) {
return mgr, nil
}
// NewManagerWithRestConfig creates a new controller-runtime manager with the given rest.Config.
// This is useful for testing where you have a pre-existing cluster configuration.
func NewManagerWithRestConfig(opts ManagerOptions, restConfig *rest.Config) (ctrl.Manager, error) {
cfg := opts.Config
le := cfg.LeaderElection
mgrOpts := ctrl.Options{
Scheme: runtimeScheme,
Metrics: ctrlmetrics.Options{
BindAddress: "0", // Disable metrics server in tests
},
HealthProbeBindAddress: "0", // Disable health probes in tests
// Leader election configuration
LeaderElection: cfg.EnableHA,
LeaderElectionID: le.LockName,
LeaderElectionNamespace: le.Namespace,
LeaderElectionReleaseOnCancel: le.ReleaseOnCancel,
LeaseDuration: &le.LeaseDuration,
RenewDeadline: &le.RenewDeadline,
RetryPeriod: &le.RetryPeriod,
}
mgr, err := ctrl.NewManager(restConfig, mgrOpts)
if err != nil {
return nil, fmt.Errorf("creating manager: %w", err)
}
return mgr, nil
}
// SetupReconcilers sets up all reconcilers with the manager.
func SetupReconcilers(mgr ctrl.Manager, cfg *config.Config, log logr.Logger, collectors *metrics.Collectors) error {
registry := workload.NewRegistry(cfg.ArgoRolloutsEnabled)
@@ -1,25 +1,16 @@
package controller_test
import (
"context"
"testing"
"github.com/go-logr/logr/testr"
"github.com/stakater/Reloader/internal/pkg/config"
"github.com/stakater/Reloader/internal/pkg/controller"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/labels"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/types"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client/fake"
)
func TestNamespaceCache_Basic(t *testing.T) {
cache := controller.NewNamespaceCache(true)
// Test Add and Contains
cache.Add("namespace-1")
if !cache.Contains("namespace-1") {
t.Error("Cache should contain namespace-1")
@@ -28,7 +19,6 @@ func TestNamespaceCache_Basic(t *testing.T) {
t.Error("Cache should not contain namespace-2")
}
// Test Remove
cache.Remove("namespace-1")
if cache.Contains("namespace-1") {
t.Error("Cache should not contain namespace-1 after removal")
@@ -38,13 +28,9 @@ func TestNamespaceCache_Basic(t *testing.T) {
func TestNamespaceCache_Disabled(t *testing.T) {
cache := controller.NewNamespaceCache(false)
// When disabled, Contains should always return true
if !cache.Contains("any-namespace") {
t.Error("Disabled cache should return true for any namespace")
}
if !cache.Contains("other-namespace") {
t.Error("Disabled cache should return true for any namespace")
}
}
func TestNamespaceCache_List(t *testing.T) {
@@ -58,7 +44,6 @@ func TestNamespaceCache_List(t *testing.T) {
t.Errorf("Expected 3 namespaces, got %d", len(list))
}
// Check all namespaces are in the list
found := make(map[string]bool)
for _, ns := range list {
found[ns] = true
@@ -71,53 +56,24 @@ func TestNamespaceCache_List(t *testing.T) {
}
func TestNamespaceCache_IsEnabled(t *testing.T) {
enabledCache := controller.NewNamespaceCache(true)
disabledCache := controller.NewNamespaceCache(false)
if !enabledCache.IsEnabled() {
if !controller.NewNamespaceCache(true).IsEnabled() {
t.Error("EnabledCache.IsEnabled() should return true")
}
if disabledCache.IsEnabled() {
if controller.NewNamespaceCache(false).IsEnabled() {
t.Error("DisabledCache.IsEnabled() should return false")
}
}
func TestNamespaceReconciler_Add(t *testing.T) {
scheme := runtime.NewScheme()
_ = corev1.AddToScheme(scheme)
ns := &corev1.Namespace{
ObjectMeta: metav1.ObjectMeta{
Name: "test-ns",
Labels: map[string]string{"env": "production"},
},
}
fakeClient := fake.NewClientBuilder().
WithScheme(scheme).
WithObjects(ns).
Build()
cfg := config.NewDefault()
selector, _ := labels.Parse("env=production")
cfg.NamespaceSelectors = []labels.Selector{selector}
cache := controller.NewNamespaceCache(true)
reconciler := &controller.NamespaceReconciler{
Client: fakeClient,
Log: testr.New(t),
Config: cfg,
Cache: cache,
}
ns := testNamespace("test-ns", map[string]string{"env": "production"})
reconciler := newNamespaceReconciler(t, cfg, cache, ns)
req := ctrl.Request{
NamespacedName: types.NamespacedName{Name: "test-ns"},
}
_, err := reconciler.Reconcile(context.Background(), req)
if err != nil {
t.Fatalf("Reconcile failed: %v", err)
}
assertReconcileSuccess(t, reconciler, namespaceRequest("test-ns"))
if !cache.Contains("test-ns") {
t.Error("Cache should contain test-ns after reconcile")
@@ -125,45 +81,17 @@ func TestNamespaceReconciler_Add(t *testing.T) {
}
func TestNamespaceReconciler_Remove_LabelChange(t *testing.T) {
scheme := runtime.NewScheme()
_ = corev1.AddToScheme(scheme)
// Namespace with non-matching labels
ns := &corev1.Namespace{
ObjectMeta: metav1.ObjectMeta{
Name: "test-ns",
Labels: map[string]string{"env": "staging"},
},
}
fakeClient := fake.NewClientBuilder().
WithScheme(scheme).
WithObjects(ns).
Build()
cfg := config.NewDefault()
selector, _ := labels.Parse("env=production")
cfg.NamespaceSelectors = []labels.Selector{selector}
cache := controller.NewNamespaceCache(true)
// Pre-populate cache
cache.Add("test-ns")
cache.Add("test-ns") // Pre-populate
reconciler := &controller.NamespaceReconciler{
Client: fakeClient,
Log: testr.New(t),
Config: cfg,
Cache: cache,
}
ns := testNamespace("test-ns", map[string]string{"env": "staging"}) // Non-matching
reconciler := newNamespaceReconciler(t, cfg, cache, ns)
req := ctrl.Request{
NamespacedName: types.NamespacedName{Name: "test-ns"},
}
_, err := reconciler.Reconcile(context.Background(), req)
if err != nil {
t.Fatalf("Reconcile failed: %v", err)
}
assertReconcileSuccess(t, reconciler, namespaceRequest("test-ns"))
if cache.Contains("test-ns") {
t.Error("Cache should not contain test-ns after reconcile (labels no longer match)")
@@ -171,37 +99,16 @@ func TestNamespaceReconciler_Remove_LabelChange(t *testing.T) {
}
func TestNamespaceReconciler_Remove_Delete(t *testing.T) {
scheme := runtime.NewScheme()
_ = corev1.AddToScheme(scheme)
// No namespace in cluster (simulates delete)
fakeClient := fake.NewClientBuilder().
WithScheme(scheme).
Build()
cfg := config.NewDefault()
selector, _ := labels.Parse("env=production")
cfg.NamespaceSelectors = []labels.Selector{selector}
cache := controller.NewNamespaceCache(true)
// Pre-populate cache
cache.Add("deleted-ns")
cache.Add("deleted-ns") // Pre-populate
reconciler := &controller.NamespaceReconciler{
Client: fakeClient,
Log: testr.New(t),
Config: cfg,
Cache: cache,
}
reconciler := newNamespaceReconciler(t, cfg, cache) // No namespace in cluster
req := ctrl.Request{
NamespacedName: types.NamespacedName{Name: "deleted-ns"},
}
_, err := reconciler.Reconcile(context.Background(), req)
if err != nil {
t.Fatalf("Reconcile failed: %v", err)
}
assertReconcileSuccess(t, reconciler, namespaceRequest("deleted-ns"))
if cache.Contains("deleted-ns") {
t.Error("Cache should not contain deleted-ns after reconcile")
@@ -209,85 +116,32 @@ func TestNamespaceReconciler_Remove_Delete(t *testing.T) {
}
func TestNamespaceReconciler_MultipleSelectors(t *testing.T) {
scheme := runtime.NewScheme()
_ = corev1.AddToScheme(scheme)
ns := &corev1.Namespace{
ObjectMeta: metav1.ObjectMeta{
Name: "test-ns",
Labels: map[string]string{"team": "platform"},
},
}
fakeClient := fake.NewClientBuilder().
WithScheme(scheme).
WithObjects(ns).
Build()
cfg := config.NewDefault()
selector1, _ := labels.Parse("env=production")
selector2, _ := labels.Parse("team=platform")
cfg.NamespaceSelectors = []labels.Selector{selector1, selector2}
cache := controller.NewNamespaceCache(true)
reconciler := &controller.NamespaceReconciler{
Client: fakeClient,
Log: testr.New(t),
Config: cfg,
Cache: cache,
}
ns := testNamespace("test-ns", map[string]string{"team": "platform"})
reconciler := newNamespaceReconciler(t, cfg, cache, ns)
req := ctrl.Request{
NamespacedName: types.NamespacedName{Name: "test-ns"},
}
assertReconcileSuccess(t, reconciler, namespaceRequest("test-ns"))
_, err := reconciler.Reconcile(context.Background(), req)
if err != nil {
t.Fatalf("Reconcile failed: %v", err)
}
// Should be added because it matches second selector (team=platform)
if !cache.Contains("test-ns") {
t.Error("Cache should contain test-ns (matches second selector)")
}
}
func TestNamespaceReconciler_NoLabels(t *testing.T) {
scheme := runtime.NewScheme()
_ = corev1.AddToScheme(scheme)
// Namespace with no labels
ns := &corev1.Namespace{
ObjectMeta: metav1.ObjectMeta{
Name: "test-ns",
},
}
fakeClient := fake.NewClientBuilder().
WithScheme(scheme).
WithObjects(ns).
Build()
cfg := config.NewDefault()
selector, _ := labels.Parse("env=production")
cfg.NamespaceSelectors = []labels.Selector{selector}
cache := controller.NewNamespaceCache(true)
reconciler := &controller.NamespaceReconciler{
Client: fakeClient,
Log: testr.New(t),
Config: cfg,
Cache: cache,
}
ns := testNamespace("test-ns", nil) // No labels
reconciler := newNamespaceReconciler(t, cfg, cache, ns)
req := ctrl.Request{
NamespacedName: types.NamespacedName{Name: "test-ns"},
}
_, err := reconciler.Reconcile(context.Background(), req)
if err != nil {
t.Fatalf("Reconcile failed: %v", err)
}
assertReconcileSuccess(t, reconciler, namespaceRequest("test-ns"))
if cache.Contains("test-ns") {
t.Error("Cache should not contain test-ns (no labels)")
+252 -82
View File
@@ -1,118 +1,286 @@
package controller
package controller_test
import (
"context"
"testing"
"github.com/stakater/Reloader/internal/pkg/config"
"github.com/stakater/Reloader/internal/pkg/controller"
"github.com/stakater/Reloader/internal/pkg/reload"
"github.com/stakater/Reloader/internal/pkg/workload"
appsv1 "k8s.io/api/apps/v1"
batchv1 "k8s.io/api/batch/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/types"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/client/fake"
)
func TestUpdateWorkloadWithRetry_SwitchCases(t *testing.T) {
// Test that the switch statement correctly identifies workload types
// Note: Full integration tests require a fake k8s client, so we just test type detection
func TestUpdateWorkloadWithRetry_WorkloadTypes(t *testing.T) {
tests := []struct {
name string
workload workload.WorkloadAccessor
expectedKind workload.Kind
object runtime.Object
workload func(runtime.Object) workload.WorkloadAccessor
resourceType reload.ResourceType
verify func(t *testing.T, c client.Client)
}{
{
name: "deployment workload",
workload: workload.NewDeploymentWorkload(&appsv1.Deployment{
ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"},
}),
expectedKind: workload.KindDeployment,
name: "Deployment",
object: testDeployment("test-deployment", "default", nil),
workload: func(o runtime.Object) workload.WorkloadAccessor {
return workload.NewDeploymentWorkload(o.(*appsv1.Deployment))
},
resourceType: reload.ResourceTypeConfigMap,
verify: func(t *testing.T, c client.Client) {
var result appsv1.Deployment
if err := c.Get(context.Background(), types.NamespacedName{Name: "test-deployment", Namespace: "default"}, &result); err != nil {
t.Fatalf("Failed to get deployment: %v", err)
}
if result.Spec.Template.Annotations == nil {
t.Fatal("Expected pod template annotations to be set")
}
},
},
{
name: "daemonset workload",
workload: workload.NewDaemonSetWorkload(&appsv1.DaemonSet{
ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"},
}),
expectedKind: workload.KindDaemonSet,
name: "DaemonSet",
object: testDaemonSet("test-daemonset", "default", nil),
workload: func(o runtime.Object) workload.WorkloadAccessor {
return workload.NewDaemonSetWorkload(o.(*appsv1.DaemonSet))
},
resourceType: reload.ResourceTypeSecret,
verify: func(t *testing.T, c client.Client) {
var result appsv1.DaemonSet
if err := c.Get(context.Background(), types.NamespacedName{Name: "test-daemonset", Namespace: "default"}, &result); err != nil {
t.Fatalf("Failed to get daemonset: %v", err)
}
if result.Spec.Template.Annotations == nil {
t.Fatal("Expected pod template annotations to be set")
}
},
},
{
name: "statefulset workload",
workload: workload.NewStatefulSetWorkload(&appsv1.StatefulSet{
ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"},
}),
expectedKind: workload.KindStatefulSet,
name: "StatefulSet",
object: testStatefulSet("test-statefulset", "default", nil),
workload: func(o runtime.Object) workload.WorkloadAccessor {
return workload.NewStatefulSetWorkload(o.(*appsv1.StatefulSet))
},
resourceType: reload.ResourceTypeConfigMap,
verify: func(t *testing.T, c client.Client) {
var result appsv1.StatefulSet
if err := c.Get(context.Background(), types.NamespacedName{Name: "test-statefulset", Namespace: "default"}, &result); err != nil {
t.Fatalf("Failed to get statefulset: %v", err)
}
if result.Spec.Template.Annotations == nil {
t.Fatal("Expected pod template annotations to be set")
}
},
},
{
name: "job workload",
workload: workload.NewJobWorkload(&batchv1.Job{
ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"},
}),
expectedKind: workload.KindJob,
name: "Job",
object: testJob("test-job", "default"),
workload: func(o runtime.Object) workload.WorkloadAccessor {
return workload.NewJobWorkload(o.(*batchv1.Job))
},
resourceType: reload.ResourceTypeConfigMap,
verify: func(t *testing.T, c client.Client) {
var jobs batchv1.JobList
if err := c.List(context.Background(), &jobs, client.InNamespace("default")); err != nil {
t.Fatalf("Failed to list jobs: %v", err)
}
if len(jobs.Items) != 1 {
t.Errorf("Expected 1 job (recreated), got %d", len(jobs.Items))
}
},
},
{
name: "cronjob workload",
workload: workload.NewCronJobWorkload(&batchv1.CronJob{
ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"},
}),
expectedKind: workload.KindCronJob,
name: "CronJob",
object: testCronJob("test-cronjob", "default"),
workload: func(o runtime.Object) workload.WorkloadAccessor {
return workload.NewCronJobWorkload(o.(*batchv1.CronJob))
},
resourceType: reload.ResourceTypeSecret,
verify: func(t *testing.T, c client.Client) {
var jobs batchv1.JobList
if err := c.List(context.Background(), &jobs, client.InNamespace("default")); err != nil {
t.Fatalf("Failed to list jobs: %v", err)
}
if len(jobs.Items) != 1 {
t.Errorf("Expected 1 job from cronjob, got %d", len(jobs.Items))
}
if len(jobs.Items) > 0 && jobs.Items[0].Annotations["cronjob.kubernetes.io/instantiate"] != "manual" {
t.Error("Expected job to have manual instantiate annotation")
}
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Verify the workload kind is correctly identified
if tt.workload.Kind() != tt.expectedKind {
t.Errorf("workload.Kind() = %v, want %v", tt.workload.Kind(), tt.expectedKind)
}
})
t.Run(
tt.name, func(t *testing.T) {
cfg := config.NewDefault()
reloadService := reload.NewService(cfg)
fakeClient := fake.NewClientBuilder().
WithScheme(testScheme()).
WithRuntimeObjects(tt.object).
Build()
wl := tt.workload(tt.object)
updated, err := controller.UpdateWorkloadWithRetry(
context.Background(),
fakeClient,
reloadService,
wl,
"test-resource",
tt.resourceType,
"default",
"abc123",
false,
)
if err != nil {
t.Fatalf("UpdateWorkloadWithRetry failed: %v", err)
}
if !updated {
t.Error("Expected workload to be updated")
}
tt.verify(t, fakeClient)
},
)
}
}
func TestJobWorkloadTypeCast(t *testing.T) {
// Test that JobWorkload type cast works correctly
job := &batchv1.Job{
ObjectMeta: metav1.ObjectMeta{Name: "test-job", Namespace: "default"},
}
jobWl := workload.NewJobWorkload(job)
if jobWl.GetName() != "test-job" {
t.Errorf("JobWorkload.GetName() = %v, want test-job", jobWl.GetName())
}
// Test GetJob method
gotJob := jobWl.GetJob()
if gotJob.Name != "test-job" {
t.Errorf("JobWorkload.GetJob().Name = %v, want test-job", gotJob.Name)
}
// Verify it satisfies WorkloadAccessor interface
var _ workload.WorkloadAccessor = jobWl
}
func TestCronJobWorkloadTypeCast(t *testing.T) {
// Test that CronJobWorkload type cast works correctly
cronJob := &batchv1.CronJob{
ObjectMeta: metav1.ObjectMeta{Name: "test-cronjob", Namespace: "default"},
Spec: batchv1.CronJobSpec{
Schedule: "*/5 * * * *",
func TestUpdateWorkloadWithRetry_Strategies(t *testing.T) {
tests := []struct {
name string
strategy config.ReloadStrategy
verify func(t *testing.T, cfg *config.Config, result *appsv1.Deployment)
}{
{
name: "EnvVarStrategy",
strategy: config.ReloadStrategyEnvVars,
verify: func(t *testing.T, cfg *config.Config, result *appsv1.Deployment) {
found := false
for _, env := range result.Spec.Template.Spec.Containers[0].Env {
if env.Name == "STAKATER_TEST_CM_CONFIGMAP" && env.Value == "abc123" {
found = true
break
}
}
if !found {
t.Error("Expected STAKATER_TEST_CM_CONFIGMAP env var to be set")
}
},
},
{
name: "AnnotationStrategy",
strategy: config.ReloadStrategyAnnotations,
verify: func(t *testing.T, cfg *config.Config, result *appsv1.Deployment) {
if result.Spec.Template.Annotations == nil {
t.Fatal("Expected pod template annotations to be set")
}
if _, ok := result.Spec.Template.Annotations[cfg.Annotations.LastReloadedFrom]; !ok {
t.Errorf("Expected %s annotation to be set", cfg.Annotations.LastReloadedFrom)
}
for _, env := range result.Spec.Template.Spec.Containers[0].Env {
if env.Name == "STAKATER_TEST_CM_CONFIGMAP" {
t.Error("Annotation strategy should not add env vars")
}
}
},
},
}
cronJobWl := workload.NewCronJobWorkload(cronJob)
if cronJobWl.GetName() != "test-cronjob" {
t.Errorf("CronJobWorkload.GetName() = %v, want test-cronjob", cronJobWl.GetName())
for _, tt := range tests {
t.Run(
tt.name, func(t *testing.T) {
cfg := config.NewDefault()
cfg.ReloadStrategy = tt.strategy
reloadService := reload.NewService(cfg)
deployment := testDeployment("test-deployment", "default", nil)
fakeClient := fake.NewClientBuilder().
WithScheme(testScheme()).
WithObjects(deployment).
Build()
wl := workload.NewDeploymentWorkload(deployment)
updated, err := controller.UpdateWorkloadWithRetry(
context.Background(),
fakeClient,
reloadService,
wl,
"test-cm",
reload.ResourceTypeConfigMap,
"default",
"abc123",
false,
)
if err != nil {
t.Fatalf("UpdateWorkloadWithRetry failed: %v", err)
}
if !updated {
t.Error("Expected workload to be updated")
}
var result appsv1.Deployment
if err := fakeClient.Get(
context.Background(), types.NamespacedName{Name: "test-deployment", Namespace: "default"}, &result,
); err != nil {
t.Fatalf("Failed to get deployment: %v", err)
}
tt.verify(t, cfg, &result)
},
)
}
}
func TestUpdateWorkloadWithRetry_NoUpdate(t *testing.T) {
cfg := config.NewDefault()
reloadService := reload.NewService(cfg)
deployment := testDeployment("test-deployment", "default", nil)
deployment.Spec.Template.Spec.Containers[0].Env = []corev1.EnvVar{
{
Name: "STAKATER_TEST_CM_CONFIGMAP",
Value: "abc123",
},
}
// Test GetCronJob method
gotCronJob := cronJobWl.GetCronJob()
if gotCronJob.Name != "test-cronjob" {
t.Errorf("CronJobWorkload.GetCronJob().Name = %v, want test-cronjob", gotCronJob.Name)
}
fakeClient := fake.NewClientBuilder().
WithScheme(testScheme()).
WithObjects(deployment).
Build()
// Verify it satisfies WorkloadAccessor interface
var _ workload.WorkloadAccessor = cronJobWl
wl := workload.NewDeploymentWorkload(deployment)
updated, err := controller.UpdateWorkloadWithRetry(
context.Background(),
fakeClient,
reloadService,
wl,
"test-cm",
reload.ResourceTypeConfigMap,
"default",
"abc123", // Same hash as already set
false,
)
if err != nil {
t.Fatalf("UpdateWorkloadWithRetry failed: %v", err)
}
if updated {
t.Error("Expected workload NOT to be updated (same hash)")
}
}
func TestResourceTypeKind(t *testing.T) {
// Test that ResourceType.Kind() returns correct values
tests := []struct {
resourceType reload.ResourceType
expectedKind string
@@ -122,10 +290,12 @@ func TestResourceTypeKind(t *testing.T) {
}
for _, tt := range tests {
t.Run(string(tt.resourceType), func(t *testing.T) {
if got := tt.resourceType.Kind(); got != tt.expectedKind {
t.Errorf("ResourceType.Kind() = %v, want %v", got, tt.expectedKind)
}
})
t.Run(
string(tt.resourceType), func(t *testing.T) {
if got := tt.resourceType.Kind(); got != tt.expectedKind {
t.Errorf("ResourceType.Kind() = %v, want %v", got, tt.expectedKind)
}
},
)
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,418 @@
package controller_test
import (
"context"
"testing"
"github.com/go-logr/logr/testr"
"github.com/stakater/Reloader/internal/pkg/alerting"
"github.com/stakater/Reloader/internal/pkg/config"
"github.com/stakater/Reloader/internal/pkg/controller"
"github.com/stakater/Reloader/internal/pkg/events"
"github.com/stakater/Reloader/internal/pkg/metrics"
"github.com/stakater/Reloader/internal/pkg/reload"
"github.com/stakater/Reloader/internal/pkg/webhook"
"github.com/stakater/Reloader/internal/pkg/workload"
appsv1 "k8s.io/api/apps/v1"
batchv1 "k8s.io/api/batch/v1"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/types"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client/fake"
)
// testScheme is a shared scheme for all controller tests.
func testScheme() *runtime.Scheme {
scheme := runtime.NewScheme()
_ = corev1.AddToScheme(scheme)
_ = appsv1.AddToScheme(scheme)
_ = batchv1.AddToScheme(scheme)
return scheme
}
// newConfigMapReconciler creates a ConfigMapReconciler for testing.
func newConfigMapReconciler(t *testing.T, cfg *config.Config, objects ...runtime.Object) *controller.ConfigMapReconciler {
t.Helper()
fakeClient := fake.NewClientBuilder().
WithScheme(testScheme()).
WithRuntimeObjects(objects...).
Build()
collectors := metrics.NewCollectors()
return &controller.ConfigMapReconciler{
Client: fakeClient,
Log: testr.New(t),
Config: cfg,
ReloadService: reload.NewService(cfg),
Registry: workload.NewRegistry(cfg.ArgoRolloutsEnabled),
Collectors: &collectors,
EventRecorder: events.NewRecorder(nil),
WebhookClient: webhook.NewClient("", testr.New(t)),
Alerter: &alerting.NoOpAlerter{},
}
}
// newSecretReconciler creates a SecretReconciler for testing.
func newSecretReconciler(t *testing.T, cfg *config.Config, objects ...runtime.Object) *controller.SecretReconciler {
t.Helper()
fakeClient := fake.NewClientBuilder().
WithScheme(testScheme()).
WithRuntimeObjects(objects...).
Build()
collectors := metrics.NewCollectors()
return &controller.SecretReconciler{
Client: fakeClient,
Log: testr.New(t),
Config: cfg,
ReloadService: reload.NewService(cfg),
Registry: workload.NewRegistry(cfg.ArgoRolloutsEnabled),
Collectors: &collectors,
EventRecorder: events.NewRecorder(nil),
WebhookClient: webhook.NewClient("", testr.New(t)),
Alerter: &alerting.NoOpAlerter{},
}
}
// testConfigMap creates a ConfigMap for testing.
func testConfigMap(name, namespace string) *corev1.ConfigMap {
return &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{
Name: name,
Namespace: namespace,
},
Data: map[string]string{"key": "value"},
}
}
// testConfigMapWithAnnotations creates a ConfigMap with annotations.
func testConfigMapWithAnnotations(name, namespace string, annotations map[string]string) *corev1.ConfigMap {
cm := testConfigMap(name, namespace)
cm.Annotations = annotations
return cm
}
// testSecret creates a Secret for testing.
func testSecret(name, namespace string) *corev1.Secret {
return &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: name,
Namespace: namespace,
},
Data: map[string][]byte{"key": []byte("value")},
}
}
// testSecretWithAnnotations creates a Secret with annotations.
func testSecretWithAnnotations(name, namespace string, annotations map[string]string) *corev1.Secret {
secret := testSecret(name, namespace)
secret.Annotations = annotations
return secret
}
// testDeployment creates a minimal Deployment for testing.
func testDeployment(name, namespace string, annotations map[string]string) *appsv1.Deployment {
return &appsv1.Deployment{
ObjectMeta: metav1.ObjectMeta{
Name: name,
Namespace: namespace,
Annotations: annotations,
},
Spec: appsv1.DeploymentSpec{
Selector: &metav1.LabelSelector{
MatchLabels: map[string]string{"app": name},
},
Template: corev1.PodTemplateSpec{
ObjectMeta: metav1.ObjectMeta{
Labels: map[string]string{"app": name},
},
Spec: corev1.PodSpec{
Containers: []corev1.Container{
{
Name: "main",
Image: "nginx",
},
},
},
},
},
}
}
// testDeploymentWithEnvFrom creates a Deployment with EnvFrom referencing a ConfigMap or Secret.
func testDeploymentWithEnvFrom(name, namespace string, configMapName, secretName string) *appsv1.Deployment {
d := testDeployment(name, namespace, nil)
if configMapName != "" {
d.Spec.Template.Spec.Containers[0].EnvFrom = append(
d.Spec.Template.Spec.Containers[0].EnvFrom,
corev1.EnvFromSource{
ConfigMapRef: &corev1.ConfigMapEnvSource{
LocalObjectReference: corev1.LocalObjectReference{Name: configMapName},
},
},
)
}
if secretName != "" {
d.Spec.Template.Spec.Containers[0].EnvFrom = append(
d.Spec.Template.Spec.Containers[0].EnvFrom,
corev1.EnvFromSource{
SecretRef: &corev1.SecretEnvSource{
LocalObjectReference: corev1.LocalObjectReference{Name: secretName},
},
},
)
}
return d
}
// testDeploymentWithVolume creates a Deployment with a volume from ConfigMap or Secret.
func testDeploymentWithVolume(name, namespace string, configMapName, secretName string) *appsv1.Deployment {
d := testDeployment(name, namespace, nil)
d.Spec.Template.Spec.Containers[0].VolumeMounts = []corev1.VolumeMount{
{
Name: "config",
MountPath: "/etc/config",
},
}
if configMapName != "" {
d.Spec.Template.Spec.Volumes = []corev1.Volume{
{
Name: "config",
VolumeSource: corev1.VolumeSource{
ConfigMap: &corev1.ConfigMapVolumeSource{
LocalObjectReference: corev1.LocalObjectReference{Name: configMapName},
},
},
},
}
}
if secretName != "" {
d.Spec.Template.Spec.Volumes = []corev1.Volume{
{
Name: "config",
VolumeSource: corev1.VolumeSource{
Secret: &corev1.SecretVolumeSource{
SecretName: secretName,
},
},
},
}
}
return d
}
// testDeploymentWithProjectedVolume creates a Deployment with a projected volume.
func testDeploymentWithProjectedVolume(name, namespace string, configMapName, secretName string) *appsv1.Deployment {
d := testDeployment(name, namespace, nil)
d.Spec.Template.Spec.Containers[0].VolumeMounts = []corev1.VolumeMount{
{
Name: "config",
MountPath: "/etc/config",
},
}
var sources []corev1.VolumeProjection
if configMapName != "" {
sources = append(
sources, corev1.VolumeProjection{
ConfigMap: &corev1.ConfigMapProjection{
LocalObjectReference: corev1.LocalObjectReference{Name: configMapName},
},
},
)
}
if secretName != "" {
sources = append(
sources, corev1.VolumeProjection{
Secret: &corev1.SecretProjection{
LocalObjectReference: corev1.LocalObjectReference{Name: secretName},
},
},
)
}
d.Spec.Template.Spec.Volumes = []corev1.Volume{
{
Name: "config",
VolumeSource: corev1.VolumeSource{
Projected: &corev1.ProjectedVolumeSource{Sources: sources},
},
},
}
return d
}
// testDaemonSet creates a minimal DaemonSet for testing.
func testDaemonSet(name, namespace string, annotations map[string]string) *appsv1.DaemonSet {
return &appsv1.DaemonSet{
ObjectMeta: metav1.ObjectMeta{
Name: name,
Namespace: namespace,
Annotations: annotations,
},
Spec: appsv1.DaemonSetSpec{
Selector: &metav1.LabelSelector{
MatchLabels: map[string]string{"app": name},
},
Template: corev1.PodTemplateSpec{
ObjectMeta: metav1.ObjectMeta{
Labels: map[string]string{"app": name},
},
Spec: corev1.PodSpec{
Containers: []corev1.Container{
{
Name: "main",
Image: "nginx",
},
},
},
},
},
}
}
// testStatefulSet creates a minimal StatefulSet for testing.
func testStatefulSet(name, namespace string, annotations map[string]string) *appsv1.StatefulSet {
return &appsv1.StatefulSet{
ObjectMeta: metav1.ObjectMeta{
Name: name,
Namespace: namespace,
Annotations: annotations,
},
Spec: appsv1.StatefulSetSpec{
Selector: &metav1.LabelSelector{
MatchLabels: map[string]string{"app": name},
},
Template: corev1.PodTemplateSpec{
ObjectMeta: metav1.ObjectMeta{
Labels: map[string]string{"app": name},
},
Spec: corev1.PodSpec{
Containers: []corev1.Container{
{
Name: "main",
Image: "nginx",
},
},
},
},
},
}
}
// reconcileRequest creates a ctrl.Request for the given name and namespace.
func reconcileRequest(name, namespace string) ctrl.Request {
return ctrl.Request{
NamespacedName: types.NamespacedName{
Name: name,
Namespace: namespace,
},
}
}
// namespaceRequest creates a ctrl.Request for a namespace (no namespace field needed).
func namespaceRequest(name string) ctrl.Request {
return ctrl.Request{
NamespacedName: types.NamespacedName{Name: name},
}
}
// testNamespace creates a Namespace with optional labels.
func testNamespace(name string, labels map[string]string) *corev1.Namespace {
return &corev1.Namespace{
ObjectMeta: metav1.ObjectMeta{
Name: name,
Labels: labels,
},
}
}
// newNamespaceReconciler creates a NamespaceReconciler for testing.
func newNamespaceReconciler(t *testing.T, cfg *config.Config, cache *controller.NamespaceCache, objects ...runtime.Object) *controller.NamespaceReconciler {
t.Helper()
scheme := runtime.NewScheme()
_ = corev1.AddToScheme(scheme)
fakeClient := fake.NewClientBuilder().
WithScheme(scheme).
WithRuntimeObjects(objects...).
Build()
return &controller.NamespaceReconciler{
Client: fakeClient,
Log: testr.New(t),
Config: cfg,
Cache: cache,
}
}
// assertReconcileSuccess runs reconcile and asserts no error and no requeue.
func assertReconcileSuccess(t *testing.T, reconciler interface {
Reconcile(context.Context, ctrl.Request) (ctrl.Result, error)
}, req ctrl.Request) {
t.Helper()
result, err := reconciler.Reconcile(context.Background(), req)
if err != nil {
t.Fatalf("Reconcile failed: %v", err)
}
if result.Requeue {
t.Error("Should not requeue")
}
}
// testJob creates a minimal Job for testing.
func testJob(name, namespace string) *batchv1.Job {
return &batchv1.Job{
ObjectMeta: metav1.ObjectMeta{
Name: name,
Namespace: namespace,
},
Spec: batchv1.JobSpec{
Template: corev1.PodTemplateSpec{
Spec: corev1.PodSpec{
RestartPolicy: corev1.RestartPolicyNever,
Containers: []corev1.Container{
{
Name: "main",
Image: "busybox",
},
},
},
},
},
}
}
// testCronJob creates a minimal CronJob for testing.
func testCronJob(name, namespace string) *batchv1.CronJob {
return &batchv1.CronJob{
ObjectMeta: metav1.ObjectMeta{
Name: name,
Namespace: namespace,
UID: "test-uid",
},
Spec: batchv1.CronJobSpec{
Schedule: "*/5 * * * *",
JobTemplate: batchv1.JobTemplateSpec{
Spec: batchv1.JobSpec{
Template: corev1.PodTemplateSpec{
Spec: corev1.PodSpec{
RestartPolicy: corev1.RestartPolicyNever,
Containers: []corev1.Container{
{
Name: "main",
Image: "busybox",
},
},
},
},
},
},
},
}
}
+11 -26
View File
@@ -102,7 +102,6 @@ func TestNilRecorder_NoPanic(t *testing.T) {
}
func TestRecorder_NilInternalRecorder(t *testing.T) {
// Create a Recorder with nil internal recorder (edge case)
r := &Recorder{recorder: nil}
pod := &corev1.Pod{
@@ -112,26 +111,10 @@ func TestRecorder_NilInternalRecorder(t *testing.T) {
},
}
// These should not panic
r.ReloadSuccess(pod, "ConfigMap", "my-config")
r.ReloadFailed(pod, "Secret", "my-secret", errors.New("test error"))
}
func TestEventConstants(t *testing.T) {
if EventTypeNormal != corev1.EventTypeNormal {
t.Errorf("EventTypeNormal = %q, want %q", EventTypeNormal, corev1.EventTypeNormal)
}
if EventTypeWarning != corev1.EventTypeWarning {
t.Errorf("EventTypeWarning = %q, want %q", EventTypeWarning, corev1.EventTypeWarning)
}
if ReasonReloaded != "Reloaded" {
t.Errorf("ReasonReloaded = %q, want %q", ReasonReloaded, "Reloaded")
}
if ReasonReloadFailed != "ReloadFailed" {
t.Errorf("ReasonReloadFailed = %q, want %q", ReasonReloadFailed, "ReloadFailed")
}
}
func TestReloadSuccess_DifferentObjectTypes(t *testing.T) {
fakeRecorder := record.NewFakeRecorder(10)
r := NewRecorder(fakeRecorder)
@@ -155,18 +138,20 @@ func TestReloadSuccess_DifferentObjectTypes(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r.ReloadSuccess(tt.object, "ConfigMap", "my-config")
t.Run(
tt.name, func(t *testing.T) {
r.ReloadSuccess(tt.object, "ConfigMap", "my-config")
select {
case event := <-fakeRecorder.Events:
if event == "" {
select {
case event := <-fakeRecorder.Events:
if event == "" {
t.Error("Expected event to be recorded")
}
default:
t.Error("Expected event to be recorded")
}
default:
t.Error("Expected event to be recorded")
}
})
},
)
}
}
+7 -91
View File
@@ -40,8 +40,8 @@ var (
type MetaInfo struct {
// BuildInfo contains information about the build version, commit, and compilation details.
BuildInfo BuildInfo `json:"buildInfo"`
// ReloaderOptions contains all the configuration options used by this Reloader instance.
ReloaderOptions ReloaderOptions `json:"reloaderOptions"`
// Config contains all the configuration options used by this Reloader instance.
Config *config.Config `json:"config"`
// DeploymentInfo contains metadata about the Kubernetes deployment of this instance.
DeploymentInfo DeploymentInfo `json:"deploymentInfo"`
}
@@ -66,56 +66,6 @@ type DeploymentInfo struct {
Namespace string `json:"namespace"`
}
// ReloaderOptions contains the configuration options for Reloader.
// This is a subset of config.Config that's relevant for the metadata ConfigMap.
type ReloaderOptions struct {
// AutoReloadAll enables automatic reloading of all resources.
AutoReloadAll bool `json:"autoReloadAll"`
// ReloadStrategy specifies the strategy used to trigger resource reloads.
ReloadStrategy string `json:"reloadStrategy"`
// IsArgoRollouts indicates whether support for Argo Rollouts is enabled.
IsArgoRollouts bool `json:"isArgoRollouts"`
// ReloadOnCreate indicates whether to trigger reloads when resources are created.
ReloadOnCreate bool `json:"reloadOnCreate"`
// ReloadOnDelete indicates whether to trigger reloads when resources are deleted.
ReloadOnDelete bool `json:"reloadOnDelete"`
// SyncAfterRestart indicates whether to sync add events after Reloader restarts.
SyncAfterRestart bool `json:"syncAfterRestart"`
// EnableHA indicates whether High Availability mode is enabled.
EnableHA bool `json:"enableHA"`
// WebhookURL is the URL to send webhook notifications to.
WebhookURL string `json:"webhookUrl"`
// LogFormat specifies the log format to use.
LogFormat string `json:"logFormat"`
// LogLevel specifies the log level to use.
LogLevel string `json:"logLevel"`
// ResourcesToIgnore is a list of resource types to ignore.
ResourcesToIgnore []string `json:"resourcesToIgnore"`
// WorkloadTypesToIgnore is a list of workload types to ignore.
WorkloadTypesToIgnore []string `json:"workloadTypesToIgnore"`
// NamespacesToIgnore is a list of namespaces to ignore.
NamespacesToIgnore []string `json:"namespacesToIgnore"`
// NamespaceSelectors is a list of namespace label selectors.
NamespaceSelectors []string `json:"namespaceSelectors"`
// ResourceSelectors is a list of resource label selectors.
ResourceSelectors []string `json:"resourceSelectors"`
// Annotations
ConfigmapUpdateOnChangeAnnotation string `json:"configmapUpdateOnChangeAnnotation"`
SecretUpdateOnChangeAnnotation string `json:"secretUpdateOnChangeAnnotation"`
ReloaderAutoAnnotation string `json:"reloaderAutoAnnotation"`
ConfigmapReloaderAutoAnnotation string `json:"configmapReloaderAutoAnnotation"`
SecretReloaderAutoAnnotation string `json:"secretReloaderAutoAnnotation"`
IgnoreResourceAnnotation string `json:"ignoreResourceAnnotation"`
ConfigmapExcludeReloaderAnnotation string `json:"configmapExcludeReloaderAnnotation"`
SecretExcludeReloaderAnnotation string `json:"secretExcludeReloaderAnnotation"`
AutoSearchAnnotation string `json:"autoSearchAnnotation"`
SearchMatchAnnotation string `json:"searchMatchAnnotation"`
RolloutStrategyAnnotation string `json:"rolloutStrategyAnnotation"`
PauseDeploymentAnnotation string `json:"pauseDeploymentAnnotation"`
PauseDeploymentTimeAnnotation string `json:"pauseDeploymentTimeAnnotation"`
}
// NewBuildInfo creates a new BuildInfo with current build information.
func NewBuildInfo() BuildInfo {
return BuildInfo{
@@ -126,45 +76,11 @@ func NewBuildInfo() BuildInfo {
}
}
// NewReloaderOptions creates ReloaderOptions from a Config.
func NewReloaderOptions(cfg *config.Config) ReloaderOptions {
return ReloaderOptions{
AutoReloadAll: cfg.AutoReloadAll,
ReloadStrategy: string(cfg.ReloadStrategy),
IsArgoRollouts: cfg.ArgoRolloutsEnabled,
ReloadOnCreate: cfg.ReloadOnCreate,
ReloadOnDelete: cfg.ReloadOnDelete,
SyncAfterRestart: cfg.SyncAfterRestart,
EnableHA: cfg.EnableHA,
WebhookURL: cfg.WebhookURL,
LogFormat: cfg.LogFormat,
LogLevel: cfg.LogLevel,
ResourcesToIgnore: cfg.IgnoredResources,
WorkloadTypesToIgnore: cfg.IgnoredWorkloads,
NamespacesToIgnore: cfg.IgnoredNamespaces,
NamespaceSelectors: cfg.NamespaceSelectorStrings,
ResourceSelectors: cfg.ResourceSelectorStrings,
ConfigmapUpdateOnChangeAnnotation: cfg.Annotations.ConfigmapReload,
SecretUpdateOnChangeAnnotation: cfg.Annotations.SecretReload,
ReloaderAutoAnnotation: cfg.Annotations.Auto,
ConfigmapReloaderAutoAnnotation: cfg.Annotations.ConfigmapAuto,
SecretReloaderAutoAnnotation: cfg.Annotations.SecretAuto,
IgnoreResourceAnnotation: cfg.Annotations.Ignore,
ConfigmapExcludeReloaderAnnotation: cfg.Annotations.ConfigmapExclude,
SecretExcludeReloaderAnnotation: cfg.Annotations.SecretExclude,
AutoSearchAnnotation: cfg.Annotations.Search,
SearchMatchAnnotation: cfg.Annotations.Match,
RolloutStrategyAnnotation: cfg.Annotations.RolloutStrategy,
PauseDeploymentAnnotation: cfg.Annotations.PausePeriod,
PauseDeploymentTimeAnnotation: cfg.Annotations.PausedAt,
}
}
// NewMetaInfo creates a new MetaInfo from configuration.
func NewMetaInfo(cfg *config.Config) *MetaInfo {
return &MetaInfo{
BuildInfo: NewBuildInfo(),
ReloaderOptions: NewReloaderOptions(cfg),
BuildInfo: NewBuildInfo(),
Config: cfg,
DeploymentInfo: DeploymentInfo{
Name: os.Getenv(EnvReloaderDeploymentName),
Namespace: os.Getenv(EnvReloaderNamespace),
@@ -183,9 +99,9 @@ func (m *MetaInfo) ToConfigMap() *corev1.ConfigMap {
},
},
Data: map[string]string{
"buildInfo": toJSON(m.BuildInfo),
"reloaderOptions": toJSON(m.ReloaderOptions),
"deploymentInfo": toJSON(m.DeploymentInfo),
"buildInfo": toJSON(m.BuildInfo),
"config": toJSON(m.Config),
"deploymentInfo": toJSON(m.DeploymentInfo),
},
}
}
+42 -58
View File
@@ -19,7 +19,6 @@ func testLogger() logr.Logger {
}
func TestNewBuildInfo(t *testing.T) {
// Set build variables for testing
oldVersion := Version
oldCommit := Commit
oldBuildDate := BuildDate
@@ -49,7 +48,10 @@ func TestNewBuildInfo(t *testing.T) {
}
}
func TestNewReloaderOptions(t *testing.T) {
func TestNewMetaInfo(t *testing.T) {
t.Setenv(EnvReloaderNamespace, "test-ns")
t.Setenv(EnvReloaderDeploymentName, "test-deploy")
cfg := config.NewDefault()
cfg.AutoReloadAll = true
cfg.ReloadStrategy = config.ReloadStrategyAnnotations
@@ -64,53 +66,39 @@ func TestNewReloaderOptions(t *testing.T) {
cfg.IgnoredWorkloads = []string{"jobs"}
cfg.IgnoredNamespaces = []string{"kube-system"}
opts := NewReloaderOptions(cfg)
metaInfo := NewMetaInfo(cfg)
if !opts.AutoReloadAll {
if !metaInfo.Config.AutoReloadAll {
t.Error("AutoReloadAll should be true")
}
if opts.ReloadStrategy != "annotations" {
t.Errorf("ReloadStrategy = %s, want annotations", opts.ReloadStrategy)
if metaInfo.Config.ReloadStrategy != config.ReloadStrategyAnnotations {
t.Errorf("ReloadStrategy = %s, want annotations", metaInfo.Config.ReloadStrategy)
}
if !opts.IsArgoRollouts {
t.Error("IsArgoRollouts should be true")
if !metaInfo.Config.ArgoRolloutsEnabled {
t.Error("ArgoRolloutsEnabled should be true")
}
if !opts.ReloadOnCreate {
if !metaInfo.Config.ReloadOnCreate {
t.Error("ReloadOnCreate should be true")
}
if !opts.ReloadOnDelete {
if !metaInfo.Config.ReloadOnDelete {
t.Error("ReloadOnDelete should be true")
}
if !opts.EnableHA {
if !metaInfo.Config.EnableHA {
t.Error("EnableHA should be true")
}
if opts.WebhookURL != "https://example.com/webhook" {
t.Errorf("WebhookURL = %s, want https://example.com/webhook", opts.WebhookURL)
}
if opts.LogFormat != "json" {
t.Errorf("LogFormat = %s, want json", opts.LogFormat)
}
if opts.LogLevel != "debug" {
t.Errorf("LogLevel = %s, want debug", opts.LogLevel)
}
if len(opts.ResourcesToIgnore) != 1 || opts.ResourcesToIgnore[0] != "configmaps" {
t.Errorf("ResourcesToIgnore = %v, want [configmaps]", opts.ResourcesToIgnore)
}
if len(opts.WorkloadTypesToIgnore) != 1 || opts.WorkloadTypesToIgnore[0] != "jobs" {
t.Errorf("WorkloadTypesToIgnore = %v, want [jobs]", opts.WorkloadTypesToIgnore)
}
if len(opts.NamespacesToIgnore) != 1 || opts.NamespacesToIgnore[0] != "kube-system" {
t.Errorf("NamespacesToIgnore = %v, want [kube-system]", opts.NamespacesToIgnore)
if metaInfo.Config.WebhookURL != "https://example.com/webhook" {
t.Errorf("WebhookURL = %s, want https://example.com/webhook", metaInfo.Config.WebhookURL)
}
// Check annotations
if opts.ReloaderAutoAnnotation != "reloader.stakater.com/auto" {
t.Errorf("ReloaderAutoAnnotation = %s, want reloader.stakater.com/auto", opts.ReloaderAutoAnnotation)
if metaInfo.DeploymentInfo.Namespace != "test-ns" {
t.Errorf("DeploymentInfo.Namespace = %s, want test-ns", metaInfo.DeploymentInfo.Namespace)
}
if metaInfo.DeploymentInfo.Name != "test-deploy" {
t.Errorf("DeploymentInfo.Name = %s, want test-deploy", metaInfo.DeploymentInfo.Name)
}
}
func TestMetaInfo_ToConfigMap(t *testing.T) {
// Set environment variables
t.Setenv(EnvReloaderNamespace, "reloader-ns")
t.Setenv(EnvReloaderDeploymentName, "reloader-deploy")
@@ -128,12 +116,11 @@ func TestMetaInfo_ToConfigMap(t *testing.T) {
t.Errorf("Label = %s, want %s", cm.Labels[ConfigMapLabelKey], ConfigMapLabelValue)
}
// Check data fields exist
if _, ok := cm.Data["buildInfo"]; !ok {
t.Error("buildInfo data key missing")
}
if _, ok := cm.Data["reloaderOptions"]; !ok {
t.Error("reloaderOptions data key missing")
if _, ok := cm.Data["config"]; !ok {
t.Error("config data key missing")
}
if _, ok := cm.Data["deploymentInfo"]; !ok {
t.Error("deploymentInfo data key missing")
@@ -145,6 +132,11 @@ func TestMetaInfo_ToConfigMap(t *testing.T) {
t.Errorf("buildInfo is not valid JSON: %v", err)
}
var parsedConfig config.Config
if err := json.Unmarshal([]byte(cm.Data["config"]), &parsedConfig); err != nil {
t.Errorf("config is not valid JSON: %v", err)
}
// Verify deploymentInfo contains expected values
var deployInfo DeploymentInfo
if err := json.Unmarshal([]byte(cm.Data["deploymentInfo"]), &deployInfo); err != nil {
@@ -159,7 +151,6 @@ func TestMetaInfo_ToConfigMap(t *testing.T) {
}
func TestPublisher_Publish_NoNamespace(t *testing.T) {
// Ensure RELOADER_NAMESPACE is not set (empty value)
t.Setenv(EnvReloaderNamespace, "")
scheme := runtime.NewScheme()
@@ -176,7 +167,6 @@ func TestPublisher_Publish_NoNamespace(t *testing.T) {
}
func TestPublisher_Publish_CreateNew(t *testing.T) {
// Set environment variables
t.Setenv(EnvReloaderNamespace, "test-ns")
t.Setenv(EnvReloaderDeploymentName, "test-deploy")
@@ -193,7 +183,6 @@ func TestPublisher_Publish_CreateNew(t *testing.T) {
t.Errorf("Publish() error = %v", err)
}
// Verify ConfigMap was created
cm := &corev1.ConfigMap{}
err = fakeClient.Get(ctx, client.ObjectKey{Name: ConfigMapName, Namespace: "test-ns"}, cm)
if err != nil {
@@ -205,14 +194,12 @@ func TestPublisher_Publish_CreateNew(t *testing.T) {
}
func TestPublisher_Publish_UpdateExisting(t *testing.T) {
// Set environment variables
t.Setenv(EnvReloaderNamespace, "test-ns")
t.Setenv(EnvReloaderDeploymentName, "test-deploy")
scheme := runtime.NewScheme()
_ = corev1.AddToScheme(scheme)
// Create existing ConfigMap with old data
existingCM := &corev1.ConfigMap{}
existingCM.Name = ConfigMapName
existingCM.Namespace = "test-ns"
@@ -234,32 +221,28 @@ func TestPublisher_Publish_UpdateExisting(t *testing.T) {
t.Errorf("Publish() error = %v", err)
}
// Verify ConfigMap was updated
cm := &corev1.ConfigMap{}
err = fakeClient.Get(ctx, client.ObjectKey{Name: ConfigMapName, Namespace: "test-ns"}, cm)
if err != nil {
t.Errorf("Failed to get updated ConfigMap: %v", err)
}
// Check that all data keys are present
if _, ok := cm.Data["buildInfo"]; !ok {
t.Error("buildInfo data key missing after update")
}
if _, ok := cm.Data["reloaderOptions"]; !ok {
t.Error("reloaderOptions data key missing after update")
if _, ok := cm.Data["config"]; !ok {
t.Error("config data key missing after update")
}
if _, ok := cm.Data["deploymentInfo"]; !ok {
t.Error("deploymentInfo data key missing after update")
}
// Verify labels were added
if cm.Labels[ConfigMapLabelKey] != ConfigMapLabelValue {
t.Errorf("Label not updated: %s", cm.Labels[ConfigMapLabelKey])
}
}
func TestPublishMetaInfoConfigMap(t *testing.T) {
// Set environment variables
t.Setenv(EnvReloaderNamespace, "test-ns")
scheme := runtime.NewScheme()
@@ -274,7 +257,6 @@ func TestPublishMetaInfoConfigMap(t *testing.T) {
t.Errorf("PublishMetaInfoConfigMap() error = %v", err)
}
// Verify ConfigMap was created
cm := &corev1.ConfigMap{}
err = fakeClient.Get(ctx, client.ObjectKey{Name: ConfigMapName, Namespace: "test-ns"}, cm)
if err != nil {
@@ -306,17 +288,19 @@ func TestParseUTCTime(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := parseUTCTime(tt.input)
if tt.wantErr {
if !result.IsZero() {
t.Errorf("parseUTCTime(%s) should return zero time", tt.input)
t.Run(
tt.name, func(t *testing.T) {
result := parseUTCTime(tt.input)
if tt.wantErr {
if !result.IsZero() {
t.Errorf("parseUTCTime(%s) should return zero time", tt.input)
}
} else {
if result.IsZero() {
t.Errorf("parseUTCTime(%s) should not return zero time", tt.input)
}
}
} else {
if result.IsZero() {
t.Errorf("parseUTCTime(%s) should not return zero time", tt.input)
}
}
})
},
)
}
}
+18 -22
View File
@@ -21,7 +21,6 @@ func TestNewCollectors_CreatesCounters(t *testing.T) {
func TestNewCollectors_InitializesWithZero(t *testing.T) {
collectors := NewCollectors()
// Check that success=true counter is initialized to 0
metric := &dto.Metric{}
err := collectors.Reloaded.With(prometheus.Labels{"success": "true"}).Write(metric)
if err != nil {
@@ -31,7 +30,6 @@ func TestNewCollectors_InitializesWithZero(t *testing.T) {
t.Errorf("Initial success=true counter = %v, want 0", metric.Counter.GetValue())
}
// Check that success=false counter is initialized to 0
err = collectors.Reloaded.With(prometheus.Labels{"success": "false"}).Write(metric)
if err != nil {
t.Fatalf("Failed to get metric: %v", err)
@@ -95,17 +93,18 @@ func TestRecordReload_MultipleIncrements(t *testing.T) {
}
func TestRecordReload_WithNamespaceTracking(t *testing.T) {
// Enable namespace tracking
t.Setenv("METRICS_COUNT_BY_NAMESPACE", "enabled")
collectors := NewCollectors()
collectors.RecordReload(true, "kube-system")
metric := &dto.Metric{}
err := collectors.ReloadedByNamespace.With(prometheus.Labels{
"success": "true",
"namespace": "kube-system",
}).Write(metric)
err := collectors.ReloadedByNamespace.With(
prometheus.Labels{
"success": "true",
"namespace": "kube-system",
},
).Write(metric)
if err != nil {
t.Fatalf("Failed to get metric: %v", err)
}
@@ -115,14 +114,11 @@ func TestRecordReload_WithNamespaceTracking(t *testing.T) {
}
func TestRecordReload_WithoutNamespaceTracking(t *testing.T) {
// Ensure namespace tracking is disabled (t.Setenv to empty resets it)
t.Setenv("METRICS_COUNT_BY_NAMESPACE", "")
collectors := NewCollectors()
collectors.RecordReload(true, "kube-system")
// The ReloadedByNamespace counter should not be incremented
// We can verify by checking countByNamespace is false
if collectors.countByNamespace {
t.Error("countByNamespace should be false when env var is not set")
}
@@ -131,7 +127,6 @@ func TestRecordReload_WithoutNamespaceTracking(t *testing.T) {
func TestNilCollectors_NoPanic(t *testing.T) {
var c *Collectors = nil
// This should not panic
c.RecordReload(true, "default")
c.RecordReload(false, "default")
}
@@ -146,11 +141,12 @@ func TestRecordReload_DifferentNamespaces(t *testing.T) {
metric := &dto.Metric{}
// Check namespace-a has 2 reloads
err := collectors.ReloadedByNamespace.With(prometheus.Labels{
"success": "true",
"namespace": "namespace-a",
}).Write(metric)
err := collectors.ReloadedByNamespace.With(
prometheus.Labels{
"success": "true",
"namespace": "namespace-a",
},
).Write(metric)
if err != nil {
t.Fatalf("Failed to get metric: %v", err)
}
@@ -158,11 +154,12 @@ func TestRecordReload_DifferentNamespaces(t *testing.T) {
t.Errorf("namespace-a counter = %v, want 2", metric.Counter.GetValue())
}
// Check namespace-b has 1 reload
err = collectors.ReloadedByNamespace.With(prometheus.Labels{
"success": "true",
"namespace": "namespace-b",
}).Write(metric)
err = collectors.ReloadedByNamespace.With(
prometheus.Labels{
"success": "true",
"namespace": "namespace-b",
},
).Write(metric)
if err != nil {
t.Fatalf("Failed to get metric: %v", err)
}
@@ -174,7 +171,6 @@ func TestRecordReload_DifferentNamespaces(t *testing.T) {
func TestCollectors_MetricNames(t *testing.T) {
collectors := NewCollectors()
// Verify the Reloaded metric has correct description
ch := make(chan *prometheus.Desc, 10)
collectors.Reloaded.Describe(ch)
close(ch)
+44 -33
View File
@@ -9,28 +9,33 @@ import (
)
func TestFilterDecisions(t *testing.T) {
// Create some mock workloads for testing
wl1 := workload.NewDeploymentWorkload(&appsv1.Deployment{
ObjectMeta: metav1.ObjectMeta{Name: "deploy1", Namespace: "default"},
})
wl2 := workload.NewDeploymentWorkload(&appsv1.Deployment{
ObjectMeta: metav1.ObjectMeta{Name: "deploy2", Namespace: "default"},
})
wl3 := workload.NewDeploymentWorkload(&appsv1.Deployment{
ObjectMeta: metav1.ObjectMeta{Name: "deploy3", Namespace: "default"},
})
wl1 := workload.NewDeploymentWorkload(
&appsv1.Deployment{
ObjectMeta: metav1.ObjectMeta{Name: "deploy1", Namespace: "default"},
},
)
wl2 := workload.NewDeploymentWorkload(
&appsv1.Deployment{
ObjectMeta: metav1.ObjectMeta{Name: "deploy2", Namespace: "default"},
},
)
wl3 := workload.NewDeploymentWorkload(
&appsv1.Deployment{
ObjectMeta: metav1.ObjectMeta{Name: "deploy3", Namespace: "default"},
},
)
tests := []struct {
name string
decisions []ReloadDecision
wantCount int
wantNames []string
name string
decisions []ReloadDecision
wantCount int
wantNames []string
}{
{
name: "empty list",
decisions: []ReloadDecision{},
wantCount: 0,
wantNames: nil,
name: "empty list",
decisions: []ReloadDecision{},
wantCount: 0,
wantNames: nil,
},
{
name: "all should reload",
@@ -63,29 +68,35 @@ func TestFilterDecisions(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := FilterDecisions(tt.decisions)
t.Run(
tt.name, func(t *testing.T) {
result := FilterDecisions(tt.decisions)
if len(result) != tt.wantCount {
t.Errorf("FilterDecisions() returned %d decisions, want %d", len(result), tt.wantCount)
}
if len(result) != tt.wantCount {
t.Errorf("FilterDecisions() returned %d decisions, want %d", len(result), tt.wantCount)
}
if tt.wantNames != nil {
for i, d := range result {
if d.Workload.GetName() != tt.wantNames[i] {
t.Errorf("FilterDecisions()[%d].Workload.GetName() = %s, want %s",
i, d.Workload.GetName(), tt.wantNames[i])
if tt.wantNames != nil {
for i, d := range result {
if d.Workload.GetName() != tt.wantNames[i] {
t.Errorf(
"FilterDecisions()[%d].Workload.GetName() = %s, want %s",
i, d.Workload.GetName(), tt.wantNames[i],
)
}
}
}
}
})
},
)
}
}
func TestReloadDecision_Fields(t *testing.T) {
wl := workload.NewDeploymentWorkload(&appsv1.Deployment{
ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"},
})
wl := workload.NewDeploymentWorkload(
&appsv1.Deployment{
ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"},
},
)
decision := ReloadDecision{
Workload: wl,
+36 -36
View File
@@ -20,7 +20,6 @@ func TestHasher_HashConfigMap(t *testing.T) {
Data: nil,
BinaryData: nil,
},
// Empty configmap gets a valid hash (hash of empty data)
wantHash: hasher.HashConfigMap(&corev1.ConfigMap{}),
},
{
@@ -31,13 +30,14 @@ func TestHasher_HashConfigMap(t *testing.T) {
"key2": "value2",
},
},
// Hash should be deterministic
wantHash: hasher.HashConfigMap(&corev1.ConfigMap{
Data: map[string]string{
"key1": "value1",
"key2": "value2",
wantHash: hasher.HashConfigMap(
&corev1.ConfigMap{
Data: map[string]string{
"key1": "value1",
"key2": "value2",
},
},
}),
),
},
{
name: "configmap with binary data",
@@ -46,21 +46,25 @@ func TestHasher_HashConfigMap(t *testing.T) {
"binary1": []byte("binaryvalue1"),
},
},
wantHash: hasher.HashConfigMap(&corev1.ConfigMap{
BinaryData: map[string][]byte{
"binary1": []byte("binaryvalue1"),
wantHash: hasher.HashConfigMap(
&corev1.ConfigMap{
BinaryData: map[string][]byte{
"binary1": []byte("binaryvalue1"),
},
},
}),
),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := hasher.HashConfigMap(tt.cm)
if got != tt.wantHash {
t.Errorf("HashConfigMap() = %v, want %v", got, tt.wantHash)
}
})
t.Run(
tt.name, func(t *testing.T) {
got := hasher.HashConfigMap(tt.cm)
if got != tt.wantHash {
t.Errorf("HashConfigMap() = %v, want %v", got, tt.wantHash)
}
},
)
}
}
@@ -75,7 +79,6 @@ func TestHasher_HashConfigMap_Deterministic(t *testing.T) {
},
}
// Hash should be the same regardless of iteration order
hash1 := hasher.HashConfigMap(cm)
hash2 := hasher.HashConfigMap(cm)
hash3 := hasher.HashConfigMap(cm)
@@ -121,7 +124,6 @@ func TestHasher_HashSecret(t *testing.T) {
secret: &corev1.Secret{
Data: nil,
},
// Empty secret gets a valid hash (hash of empty data)
wantHash: hasher.HashSecret(&corev1.Secret{}),
},
{
@@ -132,22 +134,26 @@ func TestHasher_HashSecret(t *testing.T) {
"key2": []byte("value2"),
},
},
wantHash: hasher.HashSecret(&corev1.Secret{
Data: map[string][]byte{
"key1": []byte("value1"),
"key2": []byte("value2"),
wantHash: hasher.HashSecret(
&corev1.Secret{
Data: map[string][]byte{
"key1": []byte("value1"),
"key2": []byte("value2"),
},
},
}),
),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := hasher.HashSecret(tt.secret)
if got != tt.wantHash {
t.Errorf("HashSecret() = %v, want %v", got, tt.wantHash)
}
})
t.Run(
tt.name, func(t *testing.T) {
got := hasher.HashSecret(tt.secret)
if got != tt.wantHash {
t.Errorf("HashSecret() = %v, want %v", got, tt.wantHash)
}
},
)
}
}
@@ -162,7 +168,6 @@ func TestHasher_HashSecret_Deterministic(t *testing.T) {
},
}
// Hash should be the same regardless of iteration order
hash1 := hasher.HashSecret(secret)
hash2 := hasher.HashSecret(secret)
hash3 := hasher.HashSecret(secret)
@@ -198,20 +203,17 @@ func TestHasher_HashSecret_DifferentValues(t *testing.T) {
func TestHasher_EmptyHash(t *testing.T) {
hasher := NewHasher()
// EmptyHash returns empty string to signal deletion
emptyHash := hasher.EmptyHash()
if emptyHash != "" {
t.Errorf("EmptyHash should be empty string, got %s", emptyHash)
}
// Empty ConfigMap should have a valid hash (not empty)
cm := &corev1.ConfigMap{}
cmHash := hasher.HashConfigMap(cm)
if cmHash == "" {
t.Error("Empty ConfigMap should have a non-empty hash")
}
// Empty Secret should have a valid hash (not empty)
secret := &corev1.Secret{}
secretHash := hasher.HashSecret(secret)
if secretHash == "" {
@@ -222,13 +224,11 @@ func TestHasher_EmptyHash(t *testing.T) {
func TestHasher_NilInput(t *testing.T) {
hasher := NewHasher()
// Test nil ConfigMap - returns hash of empty content (not EmptyHash)
cmHash := hasher.HashConfigMap(nil)
if cmHash == "" {
t.Error("nil ConfigMap should return a valid hash")
}
// Test nil Secret - returns hash of empty content (not EmptyHash)
secretHash := hasher.HashSecret(nil)
if secretHash == "" {
t.Error("nil Secret should return a valid hash")
+81 -95
View File
@@ -17,7 +17,6 @@ func TestMatcher_ShouldReload(t *testing.T) {
wantAutoReload bool
description string
}{
// Ignore annotation tests
{
name: "ignore annotation on resource skips reload",
input: MatchInput{
@@ -46,8 +45,6 @@ func TestMatcher_ShouldReload(t *testing.T) {
wantAutoReload: true,
description: "Resources with ignore=false should allow reload",
},
// Exclude annotation tests
{
name: "exclude annotation skips reload",
input: MatchInput{
@@ -82,8 +79,6 @@ func TestMatcher_ShouldReload(t *testing.T) {
wantAutoReload: false,
description: "ConfigMaps in comma-separated exclude list should not trigger reload",
},
// BUG FIX: Explicit annotation checked BEFORE auto
{
name: "explicit reload annotation with auto enabled - should reload",
input: MatchInput{
@@ -133,8 +128,6 @@ func TestMatcher_ShouldReload(t *testing.T) {
wantAutoReload: false,
description: "ConfigMaps not in reload list should not trigger reload",
},
// Auto annotation tests
{
name: "auto annotation on workload triggers reload",
input: MatchInput{
@@ -205,8 +198,6 @@ func TestMatcher_ShouldReload(t *testing.T) {
wantAutoReload: false,
description: "ConfigMap-specific auto annotation should not match secrets",
},
// Search/Match annotation tests
{
name: "search annotation with matching resource",
input: MatchInput{
@@ -235,8 +226,6 @@ func TestMatcher_ShouldReload(t *testing.T) {
wantAutoReload: false,
description: "Search annotation without matching resource should not trigger reload",
},
// No annotations - should not reload
{
name: "no annotations does not trigger reload",
input: MatchInput{
@@ -251,8 +240,6 @@ func TestMatcher_ShouldReload(t *testing.T) {
wantAutoReload: false,
description: "Without any annotations, should not trigger reload",
},
// Secret tests
{
name: "secret reload annotation",
input: MatchInput{
@@ -289,19 +276,21 @@ func TestMatcher_ShouldReload(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := matcher.ShouldReload(tt.input)
t.Run(
tt.name, func(t *testing.T) {
result := matcher.ShouldReload(tt.input)
if result.ShouldReload != tt.wantReload {
t.Errorf("ShouldReload = %v, want %v (%s)", result.ShouldReload, tt.wantReload, tt.description)
}
if result.ShouldReload != tt.wantReload {
t.Errorf("ShouldReload = %v, want %v (%s)", result.ShouldReload, tt.wantReload, tt.description)
}
if result.AutoReload != tt.wantAutoReload {
t.Errorf("AutoReload = %v, want %v (%s)", result.AutoReload, tt.wantAutoReload, tt.description)
}
if result.AutoReload != tt.wantAutoReload {
t.Errorf("AutoReload = %v, want %v (%s)", result.AutoReload, tt.wantAutoReload, tt.description)
}
t.Logf("✓ %s", tt.description)
})
t.Logf("✓ %s", tt.description)
},
)
}
}
@@ -364,39 +353,31 @@ func TestMatcher_ShouldReload_AutoReloadAll(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := matcher.ShouldReload(tt.input)
t.Run(
tt.name, func(t *testing.T) {
result := matcher.ShouldReload(tt.input)
if result.ShouldReload != tt.wantReload {
t.Errorf("ShouldReload = %v, want %v (%s)", result.ShouldReload, tt.wantReload, tt.description)
}
if result.ShouldReload != tt.wantReload {
t.Errorf("ShouldReload = %v, want %v (%s)", result.ShouldReload, tt.wantReload, tt.description)
}
if result.AutoReload != tt.wantAutoReload {
t.Errorf("AutoReload = %v, want %v (%s)", result.AutoReload, tt.wantAutoReload, tt.description)
}
if result.AutoReload != tt.wantAutoReload {
t.Errorf("AutoReload = %v, want %v (%s)", result.AutoReload, tt.wantAutoReload, tt.description)
}
t.Logf("✓ %s", tt.description)
})
t.Logf("✓ %s", tt.description)
},
)
}
}
// TestMatcher_BugFix_AutoDoesNotIgnoreExplicit tests the fix for the bug where
// TestMatcher_AutoDoesNotIgnoreExplicit tests the fix for the bug where
// having reloader.stakater.com/auto: "true" would cause explicit reload annotations
// to be ignored due to an early return.
func TestMatcher_BugFix_AutoDoesNotIgnoreExplicit(t *testing.T) {
func TestMatcher_AutoDoesNotIgnoreExplicit(t *testing.T) {
cfg := config.NewDefault()
matcher := NewMatcher(cfg)
// This is the exact scenario from the bug report:
// Workload has:
// reloader.stakater.com/auto: "true" (watches all referenced CMs)
// configmap.reloader.stakater.com/reload: "external-config" (ALSO watches this one)
// Container references: app-config
//
// When "external-config" changes:
// - Expected: Reload (explicitly listed)
// - Bug behavior: No reload (auto annotation causes early return)
input := MatchInput{
ResourceName: "external-config", // Not referenced by workload
ResourceNamespace: "default",
@@ -416,12 +397,11 @@ func TestMatcher_BugFix_AutoDoesNotIgnoreExplicit(t *testing.T) {
t.Errorf("Expected ShouldReload=true for explicitly listed ConfigMap, got false")
}
// Should be marked as non-auto since it matched the explicit list
if result.AutoReload {
t.Errorf("Expected AutoReload=false for explicit match, got true")
}
t.Log("✓ Bug fixed: Explicit reload annotation works even when auto is enabled")
t.Log("✓ Explicit reload annotation works even when auto is enabled")
}
// TestMatcher_PrecedenceOrder verifies the correct order of precedence:
@@ -435,54 +415,60 @@ func TestMatcher_PrecedenceOrder(t *testing.T) {
cfg := config.NewDefault()
matcher := NewMatcher(cfg)
t.Run("explicit takes precedence over auto", func(t *testing.T) {
input := MatchInput{
ResourceName: "my-config",
ResourceNamespace: "default",
ResourceType: ResourceTypeConfigMap,
WorkloadAnnotations: map[string]string{
"reloader.stakater.com/auto": "true",
"configmap.reloader.stakater.com/reload": "my-config",
},
}
result := matcher.ShouldReload(input)
if result.AutoReload {
t.Error("Expected explicit match (AutoReload=false), got auto match")
}
if !result.ShouldReload {
t.Error("Expected ShouldReload=true")
}
})
t.Run(
"explicit takes precedence over auto", func(t *testing.T) {
input := MatchInput{
ResourceName: "my-config",
ResourceNamespace: "default",
ResourceType: ResourceTypeConfigMap,
WorkloadAnnotations: map[string]string{
"reloader.stakater.com/auto": "true",
"configmap.reloader.stakater.com/reload": "my-config",
},
}
result := matcher.ShouldReload(input)
if result.AutoReload {
t.Error("Expected explicit match (AutoReload=false), got auto match")
}
if !result.ShouldReload {
t.Error("Expected ShouldReload=true")
}
},
)
t.Run("ignore takes precedence over explicit", func(t *testing.T) {
input := MatchInput{
ResourceName: "my-config",
ResourceNamespace: "default",
ResourceType: ResourceTypeConfigMap,
ResourceAnnotations: map[string]string{"reloader.stakater.com/ignore": "true"},
WorkloadAnnotations: map[string]string{
"configmap.reloader.stakater.com/reload": "my-config",
},
}
result := matcher.ShouldReload(input)
if result.ShouldReload {
t.Error("Expected ignore to take precedence, but got ShouldReload=true")
}
})
t.Run(
"ignore takes precedence over explicit", func(t *testing.T) {
input := MatchInput{
ResourceName: "my-config",
ResourceNamespace: "default",
ResourceType: ResourceTypeConfigMap,
ResourceAnnotations: map[string]string{"reloader.stakater.com/ignore": "true"},
WorkloadAnnotations: map[string]string{
"configmap.reloader.stakater.com/reload": "my-config",
},
}
result := matcher.ShouldReload(input)
if result.ShouldReload {
t.Error("Expected ignore to take precedence, but got ShouldReload=true")
}
},
)
t.Run("exclude takes precedence over explicit", func(t *testing.T) {
input := MatchInput{
ResourceName: "my-config",
ResourceNamespace: "default",
ResourceType: ResourceTypeConfigMap,
WorkloadAnnotations: map[string]string{
"configmap.reloader.stakater.com/reload": "my-config",
"configmaps.exclude.reloader.stakater.com/reload": "my-config",
},
}
result := matcher.ShouldReload(input)
if result.ShouldReload {
t.Error("Expected exclude to take precedence, but got ShouldReload=true")
}
})
t.Run(
"exclude takes precedence over explicit", func(t *testing.T) {
input := MatchInput{
ResourceName: "my-config",
ResourceNamespace: "default",
ResourceType: ResourceTypeConfigMap,
WorkloadAnnotations: map[string]string{
"configmap.reloader.stakater.com/reload": "my-config",
"configmaps.exclude.reloader.stakater.com/reload": "my-config",
},
}
result := matcher.ShouldReload(input)
if result.ShouldReload {
t.Error("Expected exclude to take precedence, but got ShouldReload=true")
}
},
)
}
-3
View File
@@ -58,10 +58,8 @@ func (h *PauseHandler) ApplyPause(wl workload.WorkloadAccessor) error {
deploy := deployWl.GetDeployment()
// Set paused flag
deploy.Spec.Paused = true
// Set paused-at annotation
if deploy.Annotations == nil {
deploy.Annotations = make(map[string]string)
}
@@ -109,7 +107,6 @@ func (h *PauseHandler) CheckPauseExpired(deploy *appsv1.Deployment) (expired boo
func (h *PauseHandler) ClearPause(deploy *appsv1.Deployment) {
deploy.Spec.Paused = false
delete(deploy.Annotations, h.cfg.Annotations.PausedAt)
// Keep pause-period annotation (user's config)
}
// IsPausedByReloader checks if a deployment was paused by Reloader.
+60 -53
View File
@@ -33,26 +33,30 @@ func resourcePredicates(cfg *config.Config, hashFn func(old, new client.Object)
// ConfigMapPredicates returns predicates for filtering ConfigMap events.
func ConfigMapPredicates(cfg *config.Config, hasher *Hasher) predicate.Predicate {
return resourcePredicates(cfg, func(old, new client.Object) (string, string, bool) {
oldCM, okOld := old.(*corev1.ConfigMap)
newCM, okNew := new.(*corev1.ConfigMap)
if !okOld || !okNew {
return "", "", false
}
return hasher.HashConfigMap(oldCM), hasher.HashConfigMap(newCM), true
})
return resourcePredicates(
cfg, func(old, new client.Object) (string, string, bool) {
oldCM, okOld := old.(*corev1.ConfigMap)
newCM, okNew := new.(*corev1.ConfigMap)
if !okOld || !okNew {
return "", "", false
}
return hasher.HashConfigMap(oldCM), hasher.HashConfigMap(newCM), true
},
)
}
// SecretPredicates returns predicates for filtering Secret events.
func SecretPredicates(cfg *config.Config, hasher *Hasher) predicate.Predicate {
return resourcePredicates(cfg, func(old, new client.Object) (string, string, bool) {
oldSecret, okOld := old.(*corev1.Secret)
newSecret, okNew := new.(*corev1.Secret)
if !okOld || !okNew {
return "", "", false
}
return hasher.HashSecret(oldSecret), hasher.HashSecret(newSecret), true
})
return resourcePredicates(
cfg, func(old, new client.Object) (string, string, bool) {
oldSecret, okOld := old.(*corev1.Secret)
newSecret, okNew := new.(*corev1.Secret)
if !okOld || !okNew {
return "", "", false
}
return hasher.HashSecret(oldSecret), hasher.HashSecret(newSecret), true
},
)
}
// NamespaceChecker defines the interface for checking if a namespace is allowed.
@@ -68,47 +72,49 @@ func NamespaceFilterPredicate(cfg *config.Config) predicate.Predicate {
// NamespaceFilterPredicateWithCache returns a predicate that filters resources by namespace,
// using the provided NamespaceChecker for namespace selector filtering.
func NamespaceFilterPredicateWithCache(cfg *config.Config, nsCache NamespaceChecker) predicate.Predicate {
return predicate.NewPredicateFuncs(func(obj client.Object) bool {
namespace := obj.GetNamespace()
return predicate.NewPredicateFuncs(
func(obj client.Object) bool {
namespace := obj.GetNamespace()
// Check if namespace should be ignored
if cfg.IsNamespaceIgnored(namespace) {
return false
}
if cfg.IsNamespaceIgnored(namespace) {
return false
}
// Check namespace selector cache if provided
if nsCache != nil && !nsCache.Contains(namespace) {
return false
}
if nsCache != nil && !nsCache.Contains(namespace) {
return false
}
return true
})
return true
},
)
}
// LabelSelectorPredicate returns a predicate that filters resources by labels.
func LabelSelectorPredicate(cfg *config.Config) predicate.Predicate {
if len(cfg.ResourceSelectors) == 0 {
// No selectors configured, allow all
return predicate.NewPredicateFuncs(func(obj client.Object) bool {
return true
})
return predicate.NewPredicateFuncs(
func(obj client.Object) bool {
return true
},
)
}
return predicate.NewPredicateFuncs(func(obj client.Object) bool {
labels := obj.GetLabels()
if labels == nil {
labels = make(map[string]string)
}
// Check if any selector matches
for _, selector := range cfg.ResourceSelectors {
if selector.Matches(LabelsSet(labels)) {
return true
return predicate.NewPredicateFuncs(
func(obj client.Object) bool {
labels := obj.GetLabels()
if labels == nil {
labels = make(map[string]string)
}
}
return false
})
for _, selector := range cfg.ResourceSelectors {
if selector.Matches(LabelsSet(labels)) {
return true
}
}
return false
},
)
}
// LabelsSet implements the k8s.io/apimachinery/pkg/labels.Labels interface
@@ -128,15 +134,16 @@ func (ls LabelsSet) Get(key string) string {
// IgnoreAnnotationPredicate returns a predicate that filters out resources with the ignore annotation.
func IgnoreAnnotationPredicate(cfg *config.Config) predicate.Predicate {
return predicate.NewPredicateFuncs(func(obj client.Object) bool {
annotations := obj.GetAnnotations()
if annotations == nil {
return true
}
return predicate.NewPredicateFuncs(
func(obj client.Object) bool {
annotations := obj.GetAnnotations()
if annotations == nil {
return true
}
// Check for ignore annotation
return annotations[cfg.Annotations.Ignore] != "true"
})
return annotations[cfg.Annotations.Ignore] != "true"
},
)
}
// CombinedPredicates combines multiple predicates with AND logic.
+164 -168
View File
@@ -44,25 +44,27 @@ func TestNamespaceFilterPredicate_Create(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := config.NewDefault()
cfg.IgnoredNamespaces = tt.ignoredNamespaces
predicate := NamespaceFilterPredicate(cfg)
t.Run(
tt.name, func(t *testing.T) {
cfg := config.NewDefault()
cfg.IgnoredNamespaces = tt.ignoredNamespaces
predicate := NamespaceFilterPredicate(cfg)
cm := &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{
Name: "test-cm",
Namespace: tt.eventNamespace,
},
}
cm := &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{
Name: "test-cm",
Namespace: tt.eventNamespace,
},
}
e := event.CreateEvent{Object: cm}
got := predicate.Create(e)
e := event.CreateEvent{Object: cm}
got := predicate.Create(e)
if got != tt.wantAllow {
t.Errorf("Create() = %v, want %v", got, tt.wantAllow)
}
})
if got != tt.wantAllow {
t.Errorf("Create() = %v, want %v", got, tt.wantAllow)
}
},
)
}
}
@@ -172,36 +174,37 @@ func TestLabelSelectorPredicate_Create(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := config.NewDefault()
selector, err := labels.Parse(tt.selector)
if err != nil {
t.Fatalf("Failed to parse selector: %v", err)
}
cfg.ResourceSelectors = []labels.Selector{selector}
predicate := LabelSelectorPredicate(cfg)
t.Run(
tt.name, func(t *testing.T) {
cfg := config.NewDefault()
selector, err := labels.Parse(tt.selector)
if err != nil {
t.Fatalf("Failed to parse selector: %v", err)
}
cfg.ResourceSelectors = []labels.Selector{selector}
predicate := LabelSelectorPredicate(cfg)
cm := &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{
Name: "test-cm",
Namespace: "default",
Labels: tt.objectLabels,
},
}
cm := &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{
Name: "test-cm",
Namespace: "default",
Labels: tt.objectLabels,
},
}
e := event.CreateEvent{Object: cm}
got := predicate.Create(e)
e := event.CreateEvent{Object: cm}
got := predicate.Create(e)
if got != tt.wantAllow {
t.Errorf("Create() = %v, want %v", got, tt.wantAllow)
}
})
if got != tt.wantAllow {
t.Errorf("Create() = %v, want %v", got, tt.wantAllow)
}
},
)
}
}
func TestLabelSelectorPredicate_NoSelectors(t *testing.T) {
cfg := config.NewDefault()
// No selectors configured
predicate := LabelSelectorPredicate(cfg)
cm := &corev1.ConfigMap{
@@ -253,22 +256,24 @@ func TestLabelSelectorPredicate_MultipleSelectors(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cm := &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{
Name: "test-cm",
Namespace: "default",
Labels: tt.labels,
},
}
t.Run(
tt.name, func(t *testing.T) {
cm := &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{
Name: "test-cm",
Namespace: "default",
Labels: tt.labels,
},
}
e := event.CreateEvent{Object: cm}
got := predicate.Create(e)
e := event.CreateEvent{Object: cm}
got := predicate.Create(e)
if got != tt.wantAllow {
t.Errorf("Create() = %v, want %v", got, tt.wantAllow)
}
})
if got != tt.wantAllow {
t.Errorf("Create() = %v, want %v", got, tt.wantAllow)
}
},
)
}
}
@@ -392,34 +397,35 @@ func TestCombinedFiltering(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cm := &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{
Name: "test-cm",
Namespace: tt.namespace,
Labels: tt.labels,
},
}
t.Run(
tt.name, func(t *testing.T) {
cm := &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{
Name: "test-cm",
Namespace: tt.namespace,
Labels: tt.labels,
},
}
e := event.CreateEvent{Object: cm}
e := event.CreateEvent{Object: cm}
gotNS := nsPredicate.Create(e)
if gotNS != tt.wantNSAllow {
t.Errorf("Namespace predicate Create() = %v, want %v", gotNS, tt.wantNSAllow)
}
gotNS := nsPredicate.Create(e)
if gotNS != tt.wantNSAllow {
t.Errorf("Namespace predicate Create() = %v, want %v", gotNS, tt.wantNSAllow)
}
gotLabel := labelPredicate.Create(e)
if gotLabel != tt.wantLabelAllow {
t.Errorf("Label predicate Create() = %v, want %v", gotLabel, tt.wantLabelAllow)
}
gotLabel := labelPredicate.Create(e)
if gotLabel != tt.wantLabelAllow {
t.Errorf("Label predicate Create() = %v, want %v", gotLabel, tt.wantLabelAllow)
}
// Both must be true for the event to pass through
combinedAllow := gotNS && gotLabel
expectedCombined := tt.wantNSAllow && tt.wantLabelAllow
if combinedAllow != expectedCombined {
t.Errorf("Combined allow = %v, want %v", combinedAllow, expectedCombined)
}
})
combinedAllow := gotNS && gotLabel
expectedCombined := tt.wantNSAllow && tt.wantLabelAllow
if combinedAllow != expectedCombined {
t.Errorf("Combined allow = %v, want %v", combinedAllow, expectedCombined)
}
},
)
}
}
@@ -449,7 +455,6 @@ func TestFilteringWithSecrets(t *testing.T) {
func TestExistsLabelSelector(t *testing.T) {
cfg := config.NewDefault()
// Selector that checks if label exists (any value)
selector, _ := labels.Parse("managed")
cfg.ResourceSelectors = []labels.Selector{selector}
predicate := LabelSelectorPredicate(cfg)
@@ -482,22 +487,24 @@ func TestExistsLabelSelector(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cm := &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{
Name: "test-cm",
Namespace: "default",
Labels: tt.labels,
},
}
t.Run(
tt.name, func(t *testing.T) {
cm := &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{
Name: "test-cm",
Namespace: "default",
Labels: tt.labels,
},
}
e := event.CreateEvent{Object: cm}
got := predicate.Create(e)
e := event.CreateEvent{Object: cm}
got := predicate.Create(e)
if got != tt.wantAllow {
t.Errorf("Create() = %v, want %v", got, tt.wantAllow)
}
})
if got != tt.wantAllow {
t.Errorf("Create() = %v, want %v", got, tt.wantAllow)
}
},
)
}
}
@@ -549,27 +556,29 @@ func TestNamespaceFilterPredicateWithCache(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := config.NewDefault()
cfg.IgnoredNamespaces = tt.ignoredNamespaces
t.Run(
tt.name, func(t *testing.T) {
cfg := config.NewDefault()
cfg.IgnoredNamespaces = tt.ignoredNamespaces
cache := &mockNamespaceChecker{allowed: tt.cacheAllowed}
predicate := NamespaceFilterPredicateWithCache(cfg, cache)
cache := &mockNamespaceChecker{allowed: tt.cacheAllowed}
predicate := NamespaceFilterPredicateWithCache(cfg, cache)
cm := &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{
Name: "test-cm",
Namespace: tt.eventNamespace,
},
}
cm := &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{
Name: "test-cm",
Namespace: tt.eventNamespace,
},
}
e := event.CreateEvent{Object: cm}
got := predicate.Create(e)
e := event.CreateEvent{Object: cm}
got := predicate.Create(e)
if got != tt.wantAllow {
t.Errorf("Create() = %v, want %v", got, tt.wantAllow)
}
})
if got != tt.wantAllow {
t.Errorf("Create() = %v, want %v", got, tt.wantAllow)
}
},
)
}
}
@@ -577,7 +586,6 @@ func TestNamespaceFilterPredicateWithCache_NilCache(t *testing.T) {
cfg := config.NewDefault()
cfg.IgnoredNamespaces = []string{"kube-system"}
// Nil cache should allow all namespaces (only check ignore list)
predicate := NamespaceFilterPredicateWithCache(cfg, nil)
tests := []struct {
@@ -590,21 +598,23 @@ func TestNamespaceFilterPredicateWithCache_NilCache(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.namespace, func(t *testing.T) {
cm := &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{
Name: "test-cm",
Namespace: tt.namespace,
},
}
t.Run(
tt.namespace, func(t *testing.T) {
cm := &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{
Name: "test-cm",
Namespace: tt.namespace,
},
}
e := event.CreateEvent{Object: cm}
got := predicate.Create(e)
e := event.CreateEvent{Object: cm}
got := predicate.Create(e)
if got != tt.wantAllow {
t.Errorf("Create() = %v, want %v for namespace %s", got, tt.wantAllow, tt.namespace)
}
})
if got != tt.wantAllow {
t.Errorf("Create() = %v, want %v for namespace %s", got, tt.wantAllow, tt.namespace)
}
},
)
}
}
@@ -650,22 +660,24 @@ func TestIgnoreAnnotationPredicate_Create(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cm := &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{
Name: "test-cm",
Namespace: "default",
Annotations: tt.annotations,
},
}
t.Run(
tt.name, func(t *testing.T) {
cm := &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{
Name: "test-cm",
Namespace: "default",
Annotations: tt.annotations,
},
}
e := event.CreateEvent{Object: cm}
got := predicate.Create(e)
e := event.CreateEvent{Object: cm}
got := predicate.Create(e)
if got != tt.wantAllow {
t.Errorf("Create() = %v, want %v", got, tt.wantAllow)
}
})
if got != tt.wantAllow {
t.Errorf("Create() = %v, want %v", got, tt.wantAllow)
}
},
)
}
}
@@ -688,7 +700,6 @@ func TestIgnoreAnnotationPredicate_AllEventTypes(t *testing.T) {
},
}
// Test Update
if predicate.Update(event.UpdateEvent{ObjectNew: ignoredCM}) {
t.Error("Update() should block ignored resource")
}
@@ -696,7 +707,6 @@ func TestIgnoreAnnotationPredicate_AllEventTypes(t *testing.T) {
t.Error("Update() should allow non-ignored resource")
}
// Test Delete
if predicate.Delete(event.DeleteEvent{Object: ignoredCM}) {
t.Error("Delete() should block ignored resource")
}
@@ -704,7 +714,6 @@ func TestIgnoreAnnotationPredicate_AllEventTypes(t *testing.T) {
t.Error("Delete() should allow non-ignored resource")
}
// Test Generic
if predicate.Generic(event.GenericEvent{Object: ignoredCM}) {
t.Error("Generic() should block ignored resource")
}
@@ -755,22 +764,24 @@ func TestCombinedPredicates(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cm := &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{
Name: "test-cm",
Namespace: tt.namespace,
Annotations: tt.annotations,
},
}
t.Run(
tt.name, func(t *testing.T) {
cm := &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{
Name: "test-cm",
Namespace: tt.namespace,
Annotations: tt.annotations,
},
}
e := event.CreateEvent{Object: cm}
got := combined.Create(e)
e := event.CreateEvent{Object: cm}
got := combined.Create(e)
if got != tt.wantAllow {
t.Errorf("Create() = %v, want %v", got, tt.wantAllow)
}
})
if got != tt.wantAllow {
t.Errorf("Create() = %v, want %v", got, tt.wantAllow)
}
},
)
}
}
@@ -792,13 +803,11 @@ func TestConfigMapPredicates_Update(t *testing.T) {
Data: map[string]string{"key": "value2"},
}
// Same content should not trigger update
e := event.UpdateEvent{ObjectOld: oldCM, ObjectNew: newCMSameContent}
if predicate.Update(e) {
t.Error("Update() should return false when content is the same")
}
// Different content should trigger update
e = event.UpdateEvent{ObjectOld: oldCM, ObjectNew: newCMDifferentContent}
if !predicate.Update(e) {
t.Error("Update() should return true when content changed")
@@ -810,7 +819,6 @@ func TestConfigMapPredicates_InvalidTypes(t *testing.T) {
hasher := NewHasher()
predicate := ConfigMapPredicates(cfg, hasher)
// Test with non-ConfigMap types
secret := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"},
}
@@ -818,13 +826,11 @@ func TestConfigMapPredicates_InvalidTypes(t *testing.T) {
ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"},
}
// Old is secret, new is configmap - should return false
e := event.UpdateEvent{ObjectOld: secret, ObjectNew: cm}
if predicate.Update(e) {
t.Error("Update() should return false for mismatched types")
}
// Both are secrets - should return false
e = event.UpdateEvent{ObjectOld: secret, ObjectNew: secret}
if predicate.Update(e) {
t.Error("Update() should return false for non-ConfigMap types")
@@ -842,17 +848,14 @@ func TestConfigMapPredicates_CreateDeleteGeneric(t *testing.T) {
ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"},
}
// Test Create
if !predicate.Create(event.CreateEvent{Object: cm}) {
t.Error("Create() should return true when ReloadOnCreate is true")
}
// Test Delete
if !predicate.Delete(event.DeleteEvent{Object: cm}) {
t.Error("Delete() should return true when ReloadOnDelete is true")
}
// Test Generic (should always return false)
if predicate.Generic(event.GenericEvent{Object: cm}) {
t.Error("Generic() should always return false")
}
@@ -876,13 +879,11 @@ func TestSecretPredicates_Update(t *testing.T) {
Data: map[string][]byte{"key": []byte("value2")},
}
// Same content should not trigger update
e := event.UpdateEvent{ObjectOld: oldSecret, ObjectNew: newSecretSameContent}
if predicate.Update(e) {
t.Error("Update() should return false when content is the same")
}
// Different content should trigger update
e = event.UpdateEvent{ObjectOld: oldSecret, ObjectNew: newSecretDifferentContent}
if !predicate.Update(e) {
t.Error("Update() should return true when content changed")
@@ -894,7 +895,6 @@ func TestSecretPredicates_InvalidTypes(t *testing.T) {
hasher := NewHasher()
predicate := SecretPredicates(cfg, hasher)
// Test with non-Secret types
cm := &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"},
}
@@ -902,13 +902,11 @@ func TestSecretPredicates_InvalidTypes(t *testing.T) {
ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"},
}
// Old is configmap, new is secret - should return false
e := event.UpdateEvent{ObjectOld: cm, ObjectNew: secret}
if predicate.Update(e) {
t.Error("Update() should return false for mismatched types")
}
// Both are configmaps - should return false
e = event.UpdateEvent{ObjectOld: cm, ObjectNew: cm}
if predicate.Update(e) {
t.Error("Update() should return false for non-Secret types")
@@ -918,7 +916,6 @@ func TestSecretPredicates_InvalidTypes(t *testing.T) {
func TestLabelsSet(t *testing.T) {
ls := LabelsSet{"app": "test", "env": "prod"}
// Test Has
if !ls.Has("app") {
t.Error("Has(app) should return true")
}
@@ -926,7 +923,6 @@ func TestLabelsSet(t *testing.T) {
t.Error("Has(nonexistent) should return false")
}
// Test Get
if ls.Get("app") != "test" {
t.Errorf("Get(app) = %v, want test", ls.Get("app"))
}
+8 -16
View File
@@ -16,21 +16,13 @@ func TestResourceType_Kind(t *testing.T) {
}
for _, tt := range tests {
t.Run(string(tt.resourceType), func(t *testing.T) {
got := tt.resourceType.Kind()
if got != tt.want {
t.Errorf("ResourceType(%q).Kind() = %v, want %v", tt.resourceType, got, tt.want)
}
})
}
}
func TestResourceTypeConstants(t *testing.T) {
// Verify the constant values are as expected
if ResourceTypeConfigMap != "configmap" {
t.Errorf("ResourceTypeConfigMap = %v, want configmap", ResourceTypeConfigMap)
}
if ResourceTypeSecret != "secret" {
t.Errorf("ResourceTypeSecret = %v, want secret", ResourceTypeSecret)
t.Run(
string(tt.resourceType), func(t *testing.T) {
got := tt.resourceType.Kind()
if got != tt.want {
t.Errorf("ResourceType(%q).Kind() = %v, want %v", tt.resourceType, got, tt.want)
}
},
)
}
}
+142 -107
View File
@@ -16,9 +16,11 @@ func TestService_ProcessConfigMap_AutoReload(t *testing.T) {
svc := NewService(cfg)
// Create a deployment with auto annotation that uses the configmap
deploy := createTestDeployment("test-deploy", "default", map[string]string{
"reloader.stakater.com/auto": "true",
})
deploy := createTestDeployment(
"test-deploy", "default", map[string]string{
"reloader.stakater.com/auto": "true",
},
)
deploy.Spec.Template.Spec.Volumes = []corev1.Volume{
{
Name: "config-vol",
@@ -74,10 +76,11 @@ func TestService_ProcessConfigMap_ExplicitAnnotation(t *testing.T) {
cfg := config.NewDefault()
svc := NewService(cfg)
// Create a deployment with explicit configmap annotation
deploy := createTestDeployment("test-deploy", "default", map[string]string{
"configmap.reloader.stakater.com/reload": "test-cm",
})
deploy := createTestDeployment(
"test-deploy", "default", map[string]string{
"configmap.reloader.stakater.com/reload": "test-cm",
},
)
workloads := []workload.WorkloadAccessor{
workload.NewDeploymentWorkload(deploy),
@@ -118,9 +121,11 @@ func TestService_ProcessConfigMap_IgnoredResource(t *testing.T) {
svc := NewService(cfg)
// Create a deployment with auto annotation
deploy := createTestDeployment("test-deploy", "default", map[string]string{
"reloader.stakater.com/auto": "true",
})
deploy := createTestDeployment(
"test-deploy", "default", map[string]string{
"reloader.stakater.com/auto": "true",
},
)
deploy.Spec.Template.Spec.Volumes = []corev1.Volume{
{
Name: "config-vol",
@@ -172,9 +177,11 @@ func TestService_ProcessSecret_AutoReload(t *testing.T) {
svc := NewService(cfg)
// Create a deployment with auto annotation that uses the secret
deploy := createTestDeployment("test-deploy", "default", map[string]string{
"reloader.stakater.com/auto": "true",
})
deploy := createTestDeployment(
"test-deploy", "default", map[string]string{
"reloader.stakater.com/auto": "true",
},
)
deploy.Spec.Template.Spec.Volumes = []corev1.Volume{
{
Name: "secret-vol",
@@ -226,9 +233,11 @@ func TestService_ProcessConfigMap_DeleteEvent(t *testing.T) {
svc := NewService(cfg)
// Create a deployment with explicit configmap annotation
deploy := createTestDeployment("test-deploy", "default", map[string]string{
"configmap.reloader.stakater.com/reload": "test-cm",
})
deploy := createTestDeployment(
"test-deploy", "default", map[string]string{
"configmap.reloader.stakater.com/reload": "test-cm",
},
)
workloads := []workload.WorkloadAccessor{
workload.NewDeploymentWorkload(deploy),
@@ -267,9 +276,11 @@ func TestService_ProcessConfigMap_DeleteEventDisabled(t *testing.T) {
cfg.ReloadOnDelete = false // Disabled by default
svc := NewService(cfg)
deploy := createTestDeployment("test-deploy", "default", map[string]string{
"configmap.reloader.stakater.com/reload": "test-cm",
})
deploy := createTestDeployment(
"test-deploy", "default", map[string]string{
"configmap.reloader.stakater.com/reload": "test-cm",
},
)
workloads := []workload.WorkloadAccessor{
workload.NewDeploymentWorkload(deploy),
@@ -440,9 +451,11 @@ func TestService_ProcessConfigMap_MultipleWorkloads(t *testing.T) {
svc := NewService(cfg)
// Create multiple workloads
deploy1 := createTestDeployment("deploy1", "default", map[string]string{
"reloader.stakater.com/auto": "true",
})
deploy1 := createTestDeployment(
"deploy1", "default", map[string]string{
"reloader.stakater.com/auto": "true",
},
)
deploy1.Spec.Template.Spec.Volumes = []corev1.Volume{
{
Name: "config-vol",
@@ -456,9 +469,11 @@ func TestService_ProcessConfigMap_MultipleWorkloads(t *testing.T) {
},
}
deploy2 := createTestDeployment("deploy2", "default", map[string]string{
"reloader.stakater.com/auto": "true",
})
deploy2 := createTestDeployment(
"deploy2", "default", map[string]string{
"reloader.stakater.com/auto": "true",
},
)
deploy2.Spec.Template.Spec.Volumes = []corev1.Volume{
{
Name: "config-vol",
@@ -473,9 +488,11 @@ func TestService_ProcessConfigMap_MultipleWorkloads(t *testing.T) {
}
// Deploy3 doesn't use the configmap
deploy3 := createTestDeployment("deploy3", "default", map[string]string{
"reloader.stakater.com/auto": "true",
})
deploy3 := createTestDeployment(
"deploy3", "default", map[string]string{
"reloader.stakater.com/auto": "true",
},
)
workloads := []workload.WorkloadAccessor{
workload.NewDeploymentWorkload(deploy1),
@@ -521,9 +538,11 @@ func TestService_ProcessConfigMap_DifferentNamespaces(t *testing.T) {
svc := NewService(cfg)
// Create deployments in different namespaces
deploy1 := createTestDeployment("deploy1", "namespace-a", map[string]string{
"reloader.stakater.com/auto": "true",
})
deploy1 := createTestDeployment(
"deploy1", "namespace-a", map[string]string{
"reloader.stakater.com/auto": "true",
},
)
deploy1.Spec.Template.Spec.Volumes = []corev1.Volume{
{
Name: "config-vol",
@@ -537,9 +556,11 @@ func TestService_ProcessConfigMap_DifferentNamespaces(t *testing.T) {
},
}
deploy2 := createTestDeployment("deploy2", "namespace-b", map[string]string{
"reloader.stakater.com/auto": "true",
})
deploy2 := createTestDeployment(
"deploy2", "namespace-b", map[string]string{
"reloader.stakater.com/auto": "true",
},
)
deploy2.Spec.Template.Spec.Volumes = []corev1.Volume{
{
Name: "config-vol",
@@ -624,17 +645,19 @@ func TestService_shouldProcessEvent(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := config.NewDefault()
cfg.ReloadOnCreate = tt.reloadOnCreate
cfg.ReloadOnDelete = tt.reloadOnDelete
svc := NewService(cfg)
t.Run(
tt.name, func(t *testing.T) {
cfg := config.NewDefault()
cfg.ReloadOnCreate = tt.reloadOnCreate
cfg.ReloadOnDelete = tt.reloadOnDelete
svc := NewService(cfg)
result := svc.shouldProcessEvent(tt.eventType)
if result != tt.expected {
t.Errorf("shouldProcessEvent(%s) = %v, want %v", tt.eventType, result, tt.expected)
}
})
result := svc.shouldProcessEvent(tt.eventType)
if result != tt.expected {
t.Errorf("shouldProcessEvent(%s) = %v, want %v", tt.eventType, result, tt.expected)
}
},
)
}
}
@@ -713,12 +736,14 @@ func TestService_findVolumeUsingResource_ConfigMap(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := svc.findVolumeUsingResource(tt.volumes, tt.resourceName, tt.resourceType)
if got != tt.wantVolume {
t.Errorf("findVolumeUsingResource() = %q, want %q", got, tt.wantVolume)
}
})
t.Run(
tt.name, func(t *testing.T) {
got := svc.findVolumeUsingResource(tt.volumes, tt.resourceName, tt.resourceType)
if got != tt.wantVolume {
t.Errorf("findVolumeUsingResource() = %q, want %q", got, tt.wantVolume)
}
},
)
}
}
@@ -786,12 +811,14 @@ func TestService_findVolumeUsingResource_Secret(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := svc.findVolumeUsingResource(tt.volumes, tt.resourceName, ResourceTypeSecret)
if got != tt.wantVolume {
t.Errorf("findVolumeUsingResource() = %q, want %q", got, tt.wantVolume)
}
})
t.Run(
tt.name, func(t *testing.T) {
got := svc.findVolumeUsingResource(tt.volumes, tt.resourceName, ResourceTypeSecret)
if got != tt.wantVolume {
t.Errorf("findVolumeUsingResource() = %q, want %q", got, tt.wantVolume)
}
},
)
}
}
@@ -860,20 +887,22 @@ func TestService_findContainerWithVolumeMount(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := svc.findContainerWithVolumeMount(tt.containers, tt.volumeName)
if tt.shouldMatch {
if got == nil {
t.Error("Expected to find a container, got nil")
} else if got.Name != tt.wantName {
t.Errorf("findContainerWithVolumeMount() container name = %q, want %q", got.Name, tt.wantName)
t.Run(
tt.name, func(t *testing.T) {
got := svc.findContainerWithVolumeMount(tt.containers, tt.volumeName)
if tt.shouldMatch {
if got == nil {
t.Error("Expected to find a container, got nil")
} else if got.Name != tt.wantName {
t.Errorf("findContainerWithVolumeMount() container name = %q, want %q", got.Name, tt.wantName)
}
} else {
if got != nil {
t.Errorf("Expected nil, got container %q", got.Name)
}
}
} else {
if got != nil {
t.Errorf("Expected nil, got container %q", got.Name)
}
}
})
},
)
}
}
@@ -882,11 +911,11 @@ func TestService_findContainerWithEnvRef_ConfigMap(t *testing.T) {
svc := NewService(cfg)
tests := []struct {
name string
containers []corev1.Container
name string
containers []corev1.Container
resourceName string
wantName string
shouldMatch bool
wantName string
shouldMatch bool
}{
{
name: "container with ConfigMapKeyRef",
@@ -935,7 +964,7 @@ func TestService_findContainerWithEnvRef_ConfigMap(t *testing.T) {
Name: "app",
Env: []corev1.EnvVar{
{
Name: "SIMPLE_VAR",
Name: "SIMPLE_VAR",
Value: "value",
},
},
@@ -960,20 +989,22 @@ func TestService_findContainerWithEnvRef_ConfigMap(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := svc.findContainerWithEnvRef(tt.containers, tt.resourceName, ResourceTypeConfigMap)
if tt.shouldMatch {
if got == nil {
t.Error("Expected to find a container, got nil")
} else if got.Name != tt.wantName {
t.Errorf("findContainerWithEnvRef() container name = %q, want %q", got.Name, tt.wantName)
t.Run(
tt.name, func(t *testing.T) {
got := svc.findContainerWithEnvRef(tt.containers, tt.resourceName, ResourceTypeConfigMap)
if tt.shouldMatch {
if got == nil {
t.Error("Expected to find a container, got nil")
} else if got.Name != tt.wantName {
t.Errorf("findContainerWithEnvRef() container name = %q, want %q", got.Name, tt.wantName)
}
} else {
if got != nil {
t.Errorf("Expected nil, got container %q", got.Name)
}
}
} else {
if got != nil {
t.Errorf("Expected nil, got container %q", got.Name)
}
}
})
},
)
}
}
@@ -982,11 +1013,11 @@ func TestService_findContainerWithEnvRef_Secret(t *testing.T) {
svc := NewService(cfg)
tests := []struct {
name string
containers []corev1.Container
name string
containers []corev1.Container
resourceName string
wantName string
shouldMatch bool
wantName string
shouldMatch bool
}{
{
name: "container with SecretKeyRef",
@@ -1047,20 +1078,22 @@ func TestService_findContainerWithEnvRef_Secret(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := svc.findContainerWithEnvRef(tt.containers, tt.resourceName, ResourceTypeSecret)
if tt.shouldMatch {
if got == nil {
t.Error("Expected to find a container, got nil")
} else if got.Name != tt.wantName {
t.Errorf("findContainerWithEnvRef() container name = %q, want %q", got.Name, tt.wantName)
t.Run(
tt.name, func(t *testing.T) {
got := svc.findContainerWithEnvRef(tt.containers, tt.resourceName, ResourceTypeSecret)
if tt.shouldMatch {
if got == nil {
t.Error("Expected to find a container, got nil")
} else if got.Name != tt.wantName {
t.Errorf("findContainerWithEnvRef() container name = %q, want %q", got.Name, tt.wantName)
}
} else {
if got != nil {
t.Errorf("Expected nil, got container %q", got.Name)
}
}
} else {
if got != nil {
t.Errorf("Expected nil, got container %q", got.Name)
}
}
})
},
)
}
}
@@ -1302,9 +1335,11 @@ func TestService_ProcessCreateEventDisabled(t *testing.T) {
cfg.ReloadOnCreate = false
svc := NewService(cfg)
deploy := createTestDeployment("test", "default", map[string]string{
"reloader.stakater.com/auto": "true",
})
deploy := createTestDeployment(
"test", "default", map[string]string{
"reloader.stakater.com/auto": "true",
},
)
workloads := []workload.WorkloadAccessor{workload.NewDeploymentWorkload(deploy)}
cm := &corev1.ConfigMap{
+21
View File
@@ -0,0 +1,21 @@
package testutil
import (
"math/rand"
"time"
)
const letterBytes = "abcdefghijklmnopqrstuvwxyz"
func init() {
rand.Seed(time.Now().UnixNano())
}
// RandSeq generates a random string of the specified length.
func RandSeq(n int) string {
b := make([]byte, n)
for i := range b {
b[i] = letterBytes[rand.Intn(len(letterBytes))]
}
return string(b)
}
+465
View File
@@ -0,0 +1,465 @@
package testutil
import (
"context"
"crypto/sha256"
"encoding/base64"
"fmt"
"time"
"github.com/stakater/Reloader/internal/pkg/config"
appsv1 "k8s.io/api/apps/v1"
batchv1 "k8s.io/api/batch/v1"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/util/wait"
"k8s.io/client-go/kubernetes"
)
const (
// ConfigmapResourceType represents ConfigMap resource type
ConfigmapResourceType = "configmap"
// SecretResourceType represents Secret resource type
SecretResourceType = "secret"
)
// CreateNamespace creates a namespace with the given name.
func CreateNamespace(name string, client kubernetes.Interface) error {
ns := &corev1.Namespace{
ObjectMeta: metav1.ObjectMeta{
Name: name,
},
}
_, err := client.CoreV1().Namespaces().Create(context.Background(), ns, metav1.CreateOptions{})
return err
}
// DeleteNamespace deletes the namespace with the given name.
func DeleteNamespace(name string, client kubernetes.Interface) error {
return client.CoreV1().Namespaces().Delete(context.Background(), name, metav1.DeleteOptions{})
}
// CreateConfigMap creates a ConfigMap with the given name and data.
func CreateConfigMap(client kubernetes.Interface, namespace, name, data string) (*corev1.ConfigMap, error) {
cm := &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{
Name: name,
Namespace: namespace,
},
Data: map[string]string{
"url": data,
},
}
return client.CoreV1().ConfigMaps(namespace).Create(context.Background(), cm, metav1.CreateOptions{})
}
// UpdateConfigMap updates the ConfigMap with new label and/or data.
func UpdateConfigMap(cm *corev1.ConfigMap, namespace, name, label, data string) error {
if label != "" {
if cm.Labels == nil {
cm.Labels = make(map[string]string)
}
cm.Labels["test-label"] = label
}
if data != "" {
cm.Data["url"] = data
}
// Note: caller must have a client to update
return nil
}
// UpdateConfigMapWithClient updates the ConfigMap with new label and/or data.
func UpdateConfigMapWithClient(client kubernetes.Interface, namespace, name, label, data string) error {
ctx := context.Background()
cm, err := client.CoreV1().ConfigMaps(namespace).Get(ctx, name, metav1.GetOptions{})
if err != nil {
return err
}
if label != "" {
if cm.Labels == nil {
cm.Labels = make(map[string]string)
}
cm.Labels["test-label"] = label
}
if data != "" {
cm.Data["url"] = data
}
_, err = client.CoreV1().ConfigMaps(namespace).Update(ctx, cm, metav1.UpdateOptions{})
return err
}
// DeleteConfigMap deletes the ConfigMap with the given name.
func DeleteConfigMap(client kubernetes.Interface, namespace, name string) error {
return client.CoreV1().ConfigMaps(namespace).Delete(context.Background(), name, metav1.DeleteOptions{})
}
// CreateSecret creates a Secret with the given name and data.
func CreateSecret(client kubernetes.Interface, namespace, name, data string) (*corev1.Secret, error) {
secret := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: name,
Namespace: namespace,
},
Data: map[string][]byte{
"password": []byte(data),
},
}
return client.CoreV1().Secrets(namespace).Create(context.Background(), secret, metav1.CreateOptions{})
}
// UpdateSecretWithClient updates the Secret with new label and/or data.
func UpdateSecretWithClient(client kubernetes.Interface, namespace, name, label, data string) error {
ctx := context.Background()
secret, err := client.CoreV1().Secrets(namespace).Get(ctx, name, metav1.GetOptions{})
if err != nil {
return err
}
if label != "" {
if secret.Labels == nil {
secret.Labels = make(map[string]string)
}
secret.Labels["test-label"] = label
}
if data != "" {
secret.Data["password"] = []byte(data)
}
_, err = client.CoreV1().Secrets(namespace).Update(ctx, secret, metav1.UpdateOptions{})
return err
}
// DeleteSecret deletes the Secret with the given name.
func DeleteSecret(client kubernetes.Interface, namespace, name string) error {
return client.CoreV1().Secrets(namespace).Delete(context.Background(), name, metav1.DeleteOptions{})
}
// CreateDeployment creates a Deployment that references a ConfigMap/Secret.
func CreateDeployment(client kubernetes.Interface, name, namespace string, useConfigMap bool, annotations map[string]string) (*appsv1.Deployment, error) {
replicas := int32(1)
deployment := &appsv1.Deployment{
ObjectMeta: metav1.ObjectMeta{
Name: name,
Namespace: namespace,
Annotations: annotations,
},
Spec: appsv1.DeploymentSpec{
Replicas: &replicas,
Selector: &metav1.LabelSelector{
MatchLabels: map[string]string{"app": name},
},
Template: corev1.PodTemplateSpec{
ObjectMeta: metav1.ObjectMeta{
Labels: map[string]string{"app": name},
},
Spec: corev1.PodSpec{
Containers: []corev1.Container{
{
Name: "main",
Image: "busybox:1.36",
Command: []string{"sh", "-c", "while true; do sleep 3600; done"},
},
},
},
},
},
}
if useConfigMap {
deployment.Spec.Template.Spec.Containers[0].EnvFrom = []corev1.EnvFromSource{
{
ConfigMapRef: &corev1.ConfigMapEnvSource{
LocalObjectReference: corev1.LocalObjectReference{
Name: name,
},
},
},
}
} else {
deployment.Spec.Template.Spec.Containers[0].EnvFrom = []corev1.EnvFromSource{
{
SecretRef: &corev1.SecretEnvSource{
LocalObjectReference: corev1.LocalObjectReference{
Name: name,
},
},
},
}
}
return client.AppsV1().Deployments(namespace).Create(context.Background(), deployment, metav1.CreateOptions{})
}
// DeleteDeployment deletes the Deployment with the given name.
func DeleteDeployment(client kubernetes.Interface, namespace, name string) error {
return client.AppsV1().Deployments(namespace).Delete(context.Background(), name, metav1.DeleteOptions{})
}
// CreateDaemonSet creates a DaemonSet that references a ConfigMap/Secret.
func CreateDaemonSet(client kubernetes.Interface, name, namespace string, useConfigMap bool, annotations map[string]string) (*appsv1.DaemonSet, error) {
daemonset := &appsv1.DaemonSet{
ObjectMeta: metav1.ObjectMeta{
Name: name,
Namespace: namespace,
Annotations: annotations,
},
Spec: appsv1.DaemonSetSpec{
Selector: &metav1.LabelSelector{
MatchLabels: map[string]string{"app": name},
},
Template: corev1.PodTemplateSpec{
ObjectMeta: metav1.ObjectMeta{
Labels: map[string]string{"app": name},
},
Spec: corev1.PodSpec{
Containers: []corev1.Container{
{
Name: "main",
Image: "busybox:1.36",
Command: []string{"sh", "-c", "while true; do sleep 3600; done"},
},
},
},
},
},
}
if useConfigMap {
daemonset.Spec.Template.Spec.Containers[0].EnvFrom = []corev1.EnvFromSource{
{
ConfigMapRef: &corev1.ConfigMapEnvSource{
LocalObjectReference: corev1.LocalObjectReference{
Name: name,
},
},
},
}
} else {
daemonset.Spec.Template.Spec.Containers[0].EnvFrom = []corev1.EnvFromSource{
{
SecretRef: &corev1.SecretEnvSource{
LocalObjectReference: corev1.LocalObjectReference{
Name: name,
},
},
},
}
}
return client.AppsV1().DaemonSets(namespace).Create(context.Background(), daemonset, metav1.CreateOptions{})
}
// DeleteDaemonSet deletes the DaemonSet with the given name.
func DeleteDaemonSet(client kubernetes.Interface, namespace, name string) error {
return client.AppsV1().DaemonSets(namespace).Delete(context.Background(), name, metav1.DeleteOptions{})
}
// CreateStatefulSet creates a StatefulSet that references a ConfigMap/Secret.
func CreateStatefulSet(client kubernetes.Interface, name, namespace string, useConfigMap bool, annotations map[string]string) (*appsv1.StatefulSet, error) {
replicas := int32(1)
statefulset := &appsv1.StatefulSet{
ObjectMeta: metav1.ObjectMeta{
Name: name,
Namespace: namespace,
Annotations: annotations,
},
Spec: appsv1.StatefulSetSpec{
Replicas: &replicas,
Selector: &metav1.LabelSelector{
MatchLabels: map[string]string{"app": name},
},
ServiceName: name,
Template: corev1.PodTemplateSpec{
ObjectMeta: metav1.ObjectMeta{
Labels: map[string]string{"app": name},
},
Spec: corev1.PodSpec{
Containers: []corev1.Container{
{
Name: "main",
Image: "busybox:1.36",
Command: []string{"sh", "-c", "while true; do sleep 3600; done"},
},
},
},
},
},
}
if useConfigMap {
statefulset.Spec.Template.Spec.Containers[0].EnvFrom = []corev1.EnvFromSource{
{
ConfigMapRef: &corev1.ConfigMapEnvSource{
LocalObjectReference: corev1.LocalObjectReference{
Name: name,
},
},
},
}
} else {
statefulset.Spec.Template.Spec.Containers[0].EnvFrom = []corev1.EnvFromSource{
{
SecretRef: &corev1.SecretEnvSource{
LocalObjectReference: corev1.LocalObjectReference{
Name: name,
},
},
},
}
}
return client.AppsV1().StatefulSets(namespace).Create(context.Background(), statefulset, metav1.CreateOptions{})
}
// DeleteStatefulSet deletes the StatefulSet with the given name.
func DeleteStatefulSet(client kubernetes.Interface, namespace, name string) error {
return client.AppsV1().StatefulSets(namespace).Delete(context.Background(), name, metav1.DeleteOptions{})
}
// CreateCronJob creates a CronJob that references a ConfigMap/Secret.
func CreateCronJob(client kubernetes.Interface, name, namespace string, useConfigMap bool, annotations map[string]string) (*batchv1.CronJob, error) {
cronjob := &batchv1.CronJob{
ObjectMeta: metav1.ObjectMeta{
Name: name,
Namespace: namespace,
Annotations: annotations,
},
Spec: batchv1.CronJobSpec{
Schedule: "*/5 * * * *",
JobTemplate: batchv1.JobTemplateSpec{
Spec: batchv1.JobSpec{
Template: corev1.PodTemplateSpec{
Spec: corev1.PodSpec{
RestartPolicy: corev1.RestartPolicyOnFailure,
Containers: []corev1.Container{
{
Name: "main",
Image: "busybox:1.36",
Command: []string{"sh", "-c", "echo hello"},
},
},
},
},
},
},
},
}
if useConfigMap {
cronjob.Spec.JobTemplate.Spec.Template.Spec.Containers[0].EnvFrom = []corev1.EnvFromSource{
{
ConfigMapRef: &corev1.ConfigMapEnvSource{
LocalObjectReference: corev1.LocalObjectReference{
Name: name,
},
},
},
}
} else {
cronjob.Spec.JobTemplate.Spec.Template.Spec.Containers[0].EnvFrom = []corev1.EnvFromSource{
{
SecretRef: &corev1.SecretEnvSource{
LocalObjectReference: corev1.LocalObjectReference{
Name: name,
},
},
},
}
}
return client.BatchV1().CronJobs(namespace).Create(context.Background(), cronjob, metav1.CreateOptions{})
}
// DeleteCronJob deletes the CronJob with the given name.
func DeleteCronJob(client kubernetes.Interface, namespace, name string) error {
return client.BatchV1().CronJobs(namespace).Delete(context.Background(), name, metav1.DeleteOptions{})
}
// ConvertResourceToSHA converts a resource data to SHA256 hash.
func ConvertResourceToSHA(resourceType, namespace, name, data string) string {
content := fmt.Sprintf("%s/%s/%s:%s", resourceType, namespace, name, data)
hash := sha256.Sum256([]byte(content))
return base64.StdEncoding.EncodeToString(hash[:])
}
// WaitForDeploymentAnnotation waits for a deployment to have the specified annotation value.
func WaitForDeploymentAnnotation(client kubernetes.Interface, namespace, name, annotation, expectedValue string, timeout time.Duration) error {
return wait.PollImmediate(time.Second, timeout, func() (bool, error) {
deployment, err := client.AppsV1().Deployments(namespace).Get(context.Background(), name, metav1.GetOptions{})
if err != nil {
return false, nil // Keep waiting
}
value, ok := deployment.Spec.Template.Annotations[annotation]
if !ok {
return false, nil // Keep waiting
}
return value == expectedValue, nil
})
}
// WaitForDeploymentReloadedAnnotation waits for a deployment to have any reloaded annotation.
func WaitForDeploymentReloadedAnnotation(client kubernetes.Interface, namespace, name string, cfg *config.Config, timeout time.Duration) (bool, error) {
var found bool
err := wait.PollImmediate(time.Second, timeout, func() (bool, error) {
deployment, err := client.AppsV1().Deployments(namespace).Get(context.Background(), name, metav1.GetOptions{})
if err != nil {
return false, nil // Keep waiting
}
// Check for the last-reloaded-from annotation in pod template
if deployment.Spec.Template.Annotations != nil {
if _, ok := deployment.Spec.Template.Annotations[cfg.Annotations.LastReloadedFrom]; ok {
found = true
return true, nil
}
}
return false, nil
})
if err == wait.ErrWaitTimeout {
return found, nil
}
return found, err
}
// WaitForDaemonSetReloadedAnnotation waits for a daemonset to have any reloaded annotation.
func WaitForDaemonSetReloadedAnnotation(client kubernetes.Interface, namespace, name string, cfg *config.Config, timeout time.Duration) (bool, error) {
var found bool
err := wait.PollImmediate(time.Second, timeout, func() (bool, error) {
daemonset, err := client.AppsV1().DaemonSets(namespace).Get(context.Background(), name, metav1.GetOptions{})
if err != nil {
return false, nil // Keep waiting
}
// Check for the last-reloaded-from annotation in pod template
if daemonset.Spec.Template.Annotations != nil {
if _, ok := daemonset.Spec.Template.Annotations[cfg.Annotations.LastReloadedFrom]; ok {
found = true
return true, nil
}
}
return false, nil
})
if err == wait.ErrWaitTimeout {
return found, nil
}
return found, err
}
// WaitForStatefulSetReloadedAnnotation waits for a statefulset to have any reloaded annotation.
func WaitForStatefulSetReloadedAnnotation(client kubernetes.Interface, namespace, name string, cfg *config.Config, timeout time.Duration) (bool, error) {
var found bool
err := wait.PollImmediate(time.Second, timeout, func() (bool, error) {
statefulset, err := client.AppsV1().StatefulSets(namespace).Get(context.Background(), name, metav1.GetOptions{})
if err != nil {
return false, nil // Keep waiting
}
// Check for the last-reloaded-from annotation in pod template
if statefulset.Spec.Template.Annotations != nil {
if _, ok := statefulset.Spec.Template.Annotations[cfg.Annotations.LastReloadedFrom]; ok {
found = true
return true, nil
}
}
return false, nil
})
if err == wait.ErrWaitTimeout {
return found, nil
}
return found, err
}
+11 -11
View File
@@ -84,18 +84,18 @@ func (r *Registry) FromObject(obj client.Object) (WorkloadAccessor, error) {
// kindAliases maps string representations to Kind constants.
// Supports lowercase, title case, and plural forms for user convenience.
var kindAliases = map[string]Kind{
"deployment": KindDeployment,
"deployments": KindDeployment,
"daemonset": KindDaemonSet,
"daemonsets": KindDaemonSet,
"statefulset": KindStatefulSet,
"deployment": KindDeployment,
"deployments": KindDeployment,
"daemonset": KindDaemonSet,
"daemonsets": KindDaemonSet,
"statefulset": KindStatefulSet,
"statefulsets": KindStatefulSet,
"rollout": KindArgoRollout,
"rollouts": KindArgoRollout,
"job": KindJob,
"jobs": KindJob,
"cronjob": KindCronJob,
"cronjobs": KindCronJob,
"rollout": KindArgoRollout,
"rollouts": KindArgoRollout,
"job": KindJob,
"jobs": KindJob,
"cronjob": KindCronJob,
"cronjobs": KindCronJob,
}
// KindFromString converts a string to a Kind.
+2 -2
View File
@@ -61,8 +61,8 @@ func TestRegistry_ListerFor_AllKinds(t *testing.T) {
r := NewRegistry(true)
tests := []struct {
kind Kind
wantNil bool
kind Kind
wantNil bool
}{
{KindDeployment, false},
{KindDaemonSet, false},
+519
View File
@@ -0,0 +1,519 @@
// Package e2e contains end-to-end tests for Reloader.
// These tests run against a real Kubernetes cluster (or envtest).
//
// To run these tests against a real cluster:
//
// KUBECONFIG=~/.kube/config go test -v ./test/e2e/... -count=1
//
// To skip these tests when running unit tests:
//
// go test -v ./... -short
package e2e
import (
"context"
"flag"
"log"
"os"
"testing"
"time"
"github.com/go-logr/zerologr"
"github.com/rs/zerolog"
"github.com/stakater/Reloader/internal/pkg/config"
"github.com/stakater/Reloader/internal/pkg/controller"
"github.com/stakater/Reloader/internal/pkg/metrics"
"github.com/stakater/Reloader/internal/pkg/testutil"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/rest"
"k8s.io/client-go/tools/clientcmd"
ctrl "sigs.k8s.io/controller-runtime"
ctrllog "sigs.k8s.io/controller-runtime/pkg/log"
"sigs.k8s.io/controller-runtime/pkg/manager"
)
const (
testNamespacePrefix = "test-reloader-e2e-"
waitTimeout = 30 * time.Second
setupDelay = 2 * time.Second
negativeTestTimeout = 5 * time.Second
)
var (
k8sClient kubernetes.Interface
cfg *config.Config
namespace string
skipE2ETests bool
cancelManager context.CancelFunc
restCfg *rest.Config
)
// testFixture provides a clean way to set up and tear down test resources.
type testFixture struct {
t *testing.T
name string
configMaps []string
secrets []string
workloads []workloadInfo
}
type workloadInfo struct {
name string
kind string // "deployment", "daemonset", "statefulset"
}
// newFixture creates a new test fixture with a unique name prefix.
func newFixture(t *testing.T, prefix string) *testFixture {
t.Helper()
skipIfNoCluster(t)
return &testFixture{
t: t,
name: prefix + "-" + testutil.RandSeq(5),
}
}
// createConfigMap creates a ConfigMap and registers it for cleanup.
func (f *testFixture) createConfigMap(name, data string) {
f.t.Helper()
_, err := testutil.CreateConfigMap(k8sClient, namespace, name, data)
if err != nil {
f.t.Fatalf("Failed to create ConfigMap %s: %v", name, err)
}
f.configMaps = append(f.configMaps, name)
}
// createSecret creates a Secret and registers it for cleanup.
func (f *testFixture) createSecret(name, data string) {
f.t.Helper()
_, err := testutil.CreateSecret(k8sClient, namespace, name, data)
if err != nil {
f.t.Fatalf("Failed to create Secret %s: %v", name, err)
}
f.secrets = append(f.secrets, name)
}
// createDeployment creates a Deployment and registers it for cleanup.
func (f *testFixture) createDeployment(name string, useConfigMap bool, annotations map[string]string) {
f.t.Helper()
_, err := testutil.CreateDeployment(k8sClient, name, namespace, useConfigMap, annotations)
if err != nil {
f.t.Fatalf("Failed to create Deployment %s: %v", name, err)
}
f.workloads = append(f.workloads, workloadInfo{name: name, kind: "deployment"})
}
// createDaemonSet creates a DaemonSet and registers it for cleanup.
func (f *testFixture) createDaemonSet(name string, useConfigMap bool, annotations map[string]string) {
f.t.Helper()
_, err := testutil.CreateDaemonSet(k8sClient, name, namespace, useConfigMap, annotations)
if err != nil {
f.t.Fatalf("Failed to create DaemonSet %s: %v", name, err)
}
f.workloads = append(f.workloads, workloadInfo{name: name, kind: "daemonset"})
}
// createStatefulSet creates a StatefulSet and registers it for cleanup.
func (f *testFixture) createStatefulSet(name string, useConfigMap bool, annotations map[string]string) {
f.t.Helper()
_, err := testutil.CreateStatefulSet(k8sClient, name, namespace, useConfigMap, annotations)
if err != nil {
f.t.Fatalf("Failed to create StatefulSet %s: %v", name, err)
}
f.workloads = append(f.workloads, workloadInfo{name: name, kind: "statefulset"})
}
// waitForReady waits for all workloads to be ready.
func (f *testFixture) waitForReady() {
time.Sleep(setupDelay)
}
// updateConfigMap updates a ConfigMap's data.
func (f *testFixture) updateConfigMap(name, data string) {
f.t.Helper()
if err := testutil.UpdateConfigMapWithClient(k8sClient, namespace, name, "", data); err != nil {
f.t.Fatalf("Failed to update ConfigMap %s: %v", name, err)
}
}
// updateConfigMapLabel updates only a ConfigMap's label (not data).
func (f *testFixture) updateConfigMapLabel(name, label string) {
f.t.Helper()
// Get current data first
cm, err := k8sClient.CoreV1().ConfigMaps(namespace).Get(context.Background(), name, metav1.GetOptions{})
if err != nil {
f.t.Fatalf("Failed to get ConfigMap %s: %v", name, err)
}
data := cm.Data["url"]
if err := testutil.UpdateConfigMapWithClient(k8sClient, namespace, name, label, data); err != nil {
f.t.Fatalf("Failed to update ConfigMap label %s: %v", name, err)
}
}
// updateSecret updates a Secret's data.
func (f *testFixture) updateSecret(name, data string) {
f.t.Helper()
if err := testutil.UpdateSecretWithClient(k8sClient, namespace, name, "", data); err != nil {
f.t.Fatalf("Failed to update Secret %s: %v", name, err)
}
}
// assertDeploymentReloaded asserts that a deployment was reloaded.
func (f *testFixture) assertDeploymentReloaded(name string, testCfg *config.Config) {
f.t.Helper()
if testCfg == nil {
testCfg = cfg
}
updated, err := testutil.WaitForDeploymentReloadedAnnotation(k8sClient, namespace, name, testCfg, waitTimeout)
if err != nil {
f.t.Fatalf("Error waiting for deployment %s update: %v", name, err)
}
if !updated {
f.t.Errorf("Deployment %s was not updated after resource change", name)
}
}
// assertDeploymentNotReloaded asserts that a deployment was NOT reloaded.
func (f *testFixture) assertDeploymentNotReloaded(name string, testCfg *config.Config) {
f.t.Helper()
if testCfg == nil {
testCfg = cfg
}
time.Sleep(negativeTestTimeout)
updated, _ := testutil.WaitForDeploymentReloadedAnnotation(k8sClient, namespace, name, testCfg, negativeTestTimeout)
if updated {
f.t.Errorf("Deployment %s should not have been updated", name)
}
}
// assertDaemonSetReloaded asserts that a daemonset was reloaded.
func (f *testFixture) assertDaemonSetReloaded(name string) {
f.t.Helper()
updated, err := testutil.WaitForDaemonSetReloadedAnnotation(k8sClient, namespace, name, cfg, waitTimeout)
if err != nil {
f.t.Fatalf("Error waiting for daemonset %s update: %v", name, err)
}
if !updated {
f.t.Errorf("DaemonSet %s was not updated after resource change", name)
}
}
// assertStatefulSetReloaded asserts that a statefulset was reloaded.
func (f *testFixture) assertStatefulSetReloaded(name string) {
f.t.Helper()
updated, err := testutil.WaitForStatefulSetReloadedAnnotation(k8sClient, namespace, name, cfg, waitTimeout)
if err != nil {
f.t.Fatalf("Error waiting for statefulset %s update: %v", name, err)
}
if !updated {
f.t.Errorf("StatefulSet %s was not updated after resource change", name)
}
}
// cleanup removes all created resources.
func (f *testFixture) cleanup() {
for _, w := range f.workloads {
switch w.kind {
case "deployment":
_ = testutil.DeleteDeployment(k8sClient, namespace, w.name)
case "daemonset":
_ = testutil.DeleteDaemonSet(k8sClient, namespace, w.name)
case "statefulset":
_ = testutil.DeleteStatefulSet(k8sClient, namespace, w.name)
}
}
for _, name := range f.configMaps {
_ = testutil.DeleteConfigMap(k8sClient, namespace, name)
}
for _, name := range f.secrets {
_ = testutil.DeleteSecret(k8sClient, namespace, name)
}
}
func TestMain(m *testing.M) {
flag.Parse()
if testing.Short() {
os.Exit(0)
}
// Set up zerolog as the controller-runtime logger
zl := zerolog.New(zerolog.ConsoleWriter{Out: os.Stderr, TimeFormat: time.RFC3339}).
Level(zerolog.WarnLevel).
With().
Timestamp().
Logger()
ctrllog.SetLogger(zerologr.New(&zl))
kubeconfig := os.Getenv("KUBECONFIG")
if kubeconfig == "" {
kubeconfig = os.Getenv("HOME") + "/.kube/config"
}
var err error
restCfg, err = clientcmd.BuildConfigFromFlags("", kubeconfig)
if err != nil {
skipE2ETests = true
os.Exit(0)
}
k8sClient, err = kubernetes.NewForConfig(restCfg)
if err != nil {
skipE2ETests = true
os.Exit(0)
}
if _, err = k8sClient.CoreV1().Namespaces().List(context.Background(), metav1.ListOptions{}); err != nil {
skipE2ETests = true
os.Exit(0)
}
namespace = testNamespacePrefix + testutil.RandSeq(5)
if err := testutil.CreateNamespace(namespace, k8sClient); err != nil {
panic(err)
}
cfg = config.NewDefault()
cfg.AutoReloadAll = false
_, cancelManager = startManagerWithConfig(cfg, restCfg)
code := m.Run()
if cancelManager != nil {
cancelManager()
time.Sleep(2 * time.Second)
}
_ = testutil.DeleteNamespace(namespace, k8sClient)
os.Exit(code)
}
func skipIfNoCluster(t *testing.T) {
if skipE2ETests {
t.Skip("Skipping e2e test: no Kubernetes cluster available")
}
}
// TestConfigMapUpdate tests that updating a ConfigMap triggers a workload reload.
func TestConfigMapUpdate(t *testing.T) {
f := newFixture(t, "cm-update")
defer f.cleanup()
f.createConfigMap(f.name, "initial-data")
f.createDeployment(f.name, true, map[string]string{
cfg.Annotations.ConfigmapReload: f.name,
})
f.waitForReady()
f.updateConfigMap(f.name, "updated-data")
f.assertDeploymentReloaded(f.name, nil)
}
// TestSecretUpdate tests that updating a Secret triggers a workload reload.
func TestSecretUpdate(t *testing.T) {
f := newFixture(t, "secret-update")
defer f.cleanup()
f.createSecret(f.name, "initial-secret")
f.createDeployment(f.name, false, map[string]string{
cfg.Annotations.SecretReload: f.name,
})
f.waitForReady()
f.updateSecret(f.name, "updated-secret")
f.assertDeploymentReloaded(f.name, nil)
}
// TestAutoReloadAll tests the auto-reload-all feature.
func TestAutoReloadAll(t *testing.T) {
f := newFixture(t, "auto-reload")
defer f.cleanup()
f.createConfigMap(f.name, "initial-data")
f.createDeployment(f.name, true, map[string]string{
cfg.Annotations.Auto: "true",
})
f.waitForReady()
f.updateConfigMap(f.name, "updated-data")
f.assertDeploymentReloaded(f.name, nil)
}
// TestDaemonSetReload tests that DaemonSets are reloaded when ConfigMaps change.
func TestDaemonSetReload(t *testing.T) {
f := newFixture(t, "ds-reload")
defer f.cleanup()
f.createConfigMap(f.name, "initial-data")
f.createDaemonSet(f.name, true, map[string]string{
cfg.Annotations.ConfigmapReload: f.name,
})
f.waitForReady()
f.updateConfigMap(f.name, "updated-data")
f.assertDaemonSetReloaded(f.name)
}
// TestStatefulSetReload tests that StatefulSets are reloaded when Secrets change.
func TestStatefulSetReload(t *testing.T) {
f := newFixture(t, "sts-reload")
defer f.cleanup()
f.createSecret(f.name, "initial-secret")
f.createStatefulSet(f.name, false, map[string]string{
cfg.Annotations.SecretReload: f.name,
})
f.waitForReady()
f.updateSecret(f.name, "updated-secret")
f.assertStatefulSetReloaded(f.name)
}
// TestLabelOnlyChange tests that label-only changes don't trigger reloads.
func TestLabelOnlyChange(t *testing.T) {
f := newFixture(t, "label-only")
defer f.cleanup()
f.createConfigMap(f.name, "initial-data")
f.createDeployment(f.name, true, map[string]string{
cfg.Annotations.ConfigmapReload: f.name,
})
f.waitForReady()
f.updateConfigMapLabel(f.name, "new-label")
f.assertDeploymentNotReloaded(f.name, nil)
}
// TestMultipleConfigMaps tests watching multiple ConfigMaps in a single annotation.
func TestMultipleConfigMaps(t *testing.T) {
f := newFixture(t, "multi-cm")
defer f.cleanup()
cm1 := f.name + "-a"
cm2 := f.name + "-b"
f.createConfigMap(cm1, "data-a")
f.createConfigMap(cm2, "data-b")
f.createDeployment(f.name, true, map[string]string{
cfg.Annotations.ConfigmapReload: cm1 + "," + cm2,
})
f.waitForReady()
f.updateConfigMap(cm1, "updated-data-a")
f.assertDeploymentReloaded(f.name, nil)
}
// TestAutoAnnotationDisabled tests that auto: "false" disables auto-reload.
func TestAutoAnnotationDisabled(t *testing.T) {
f := newFixture(t, "auto-disabled")
defer f.cleanup()
testCfg := config.NewDefault()
testCfg.AutoReloadAll = true
f.createConfigMap(f.name, "initial-data")
f.createDeployment(f.name, true, map[string]string{
testCfg.Annotations.Auto: "false",
})
f.waitForReady()
f.updateConfigMap(f.name, "updated-data")
f.assertDeploymentNotReloaded(f.name, testCfg)
}
// TestAutoWithExplicitConfigMapAnnotation tests that a deployment with auto=true
// also reloads when an explicitly annotated (non-referenced) ConfigMap changes.
func TestAutoWithExplicitConfigMapAnnotation(t *testing.T) {
f := newFixture(t, "auto-explicit-cm")
defer f.cleanup()
referencedCM := f.name + "-ref"
explicitCM := f.name + "-explicit"
f.createConfigMap(referencedCM, "referenced-data")
f.createConfigMap(explicitCM, "explicit-data")
f.createDeployment(referencedCM, true, map[string]string{
cfg.Annotations.Auto: "true",
cfg.Annotations.ConfigmapReload: explicitCM,
})
f.waitForReady()
// Update the EXPLICIT ConfigMap (not the referenced one)
f.updateConfigMap(explicitCM, "updated-explicit-data")
f.assertDeploymentReloaded(referencedCM, nil)
}
// TestAutoWithExplicitSecretAnnotation tests that a deployment with auto=true
// also reloads when an explicitly annotated (non-referenced) Secret changes.
func TestAutoWithExplicitSecretAnnotation(t *testing.T) {
f := newFixture(t, "auto-explicit-secret")
defer f.cleanup()
referencedSecret := f.name + "-ref"
explicitSecret := f.name + "-explicit"
f.createSecret(referencedSecret, "referenced-secret")
f.createSecret(explicitSecret, "explicit-secret")
f.createDeployment(referencedSecret, false, map[string]string{
cfg.Annotations.Auto: "true",
cfg.Annotations.SecretReload: explicitSecret,
})
f.waitForReady()
// Update the EXPLICIT Secret (not the referenced one)
f.updateSecret(explicitSecret, "updated-explicit-secret")
f.assertDeploymentReloaded(referencedSecret, nil)
}
// TestAutoWithBothExplicitAndReferencedChange tests that auto + explicit annotations
// work correctly when the referenced resource changes.
func TestAutoWithBothExplicitAndReferencedChange(t *testing.T) {
f := newFixture(t, "auto-both")
defer f.cleanup()
referencedCM := f.name + "-ref"
explicitCM := f.name + "-explicit"
f.createConfigMap(referencedCM, "referenced-data")
f.createConfigMap(explicitCM, "explicit-data")
f.createDeployment(referencedCM, true, map[string]string{
cfg.Annotations.Auto: "true",
cfg.Annotations.ConfigmapReload: explicitCM,
})
f.waitForReady()
// Update the REFERENCED ConfigMap - should trigger reload via auto
f.updateConfigMap(referencedCM, "updated-referenced-data")
f.assertDeploymentReloaded(referencedCM, nil)
}
// startManagerWithConfig creates and starts a controller-runtime manager for e2e testing.
func startManagerWithConfig(cfg *config.Config, restConfig *rest.Config) (manager.Manager, context.CancelFunc) {
collectors := metrics.NewCollectors()
mgr, err := controller.NewManagerWithRestConfig(
controller.ManagerOptions{
Config: cfg,
Log: ctrl.Log.WithName("test-manager"),
Collectors: &collectors,
}, restConfig,
)
if err != nil {
log.Fatalf("Failed to create manager: %v", err)
}
if err := controller.SetupReconcilers(mgr, cfg, ctrl.Log.WithName("test-reconcilers"), &collectors); err != nil {
log.Fatalf("Failed to setup reconcilers: %v", err)
}
ctx, cancel := context.WithCancel(context.Background())
go func() {
if err := controller.RunManager(ctx, mgr, ctrl.Log.WithName("test-runner")); err != nil {
log.Printf("Manager exited: %v", err)
}
}()
time.Sleep(3 * time.Second)
return mgr, cancel
}