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
@@ -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",
},
},
},
},
},
},
},
}
}