diff --git a/go.mod b/go.mod index af8cb962..bff72edb 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.25.5 require ( github.com/argoproj/argo-rollouts v1.8.3 + github.com/go-logr/logr v1.4.2 github.com/openshift/api v0.0.0-20250411135543-10a8fa583797 github.com/openshift/client-go v0.0.0-20250402181141-b3bad3b645f2 github.com/parnurzeal/gorequest v0.3.0 @@ -27,8 +28,8 @@ require ( github.com/elazarl/goproxy v0.0.0-20240726154733-8b0c20506380 // indirect github.com/emicklei/go-restful/v3 v3.12.2 // indirect github.com/evanphx/json-patch/v5 v5.9.0 // indirect + github.com/fsnotify/fsnotify v1.7.0 // indirect github.com/fxamacker/cbor/v2 v2.8.0 // indirect - github.com/go-logr/logr v1.4.2 // indirect github.com/go-openapi/jsonpointer v0.21.1 // indirect github.com/go-openapi/jsonreference v0.21.0 // indirect github.com/go-openapi/swag v0.23.1 // indirect @@ -54,16 +55,19 @@ require ( github.com/prometheus/procfs v0.16.0 // indirect github.com/smartystreets/goconvey v1.7.2 // indirect github.com/x448/float16 v0.8.4 // indirect + golang.org/x/exp v0.0.0-20230515195305-f3d0a9c9a5cc // indirect golang.org/x/net v0.39.0 // indirect golang.org/x/oauth2 v0.29.0 // indirect golang.org/x/sys v0.32.0 // indirect golang.org/x/term v0.31.0 // indirect golang.org/x/text v0.24.0 // indirect golang.org/x/time v0.11.0 // indirect + gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect google.golang.org/protobuf v1.36.6 // indirect gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect + k8s.io/apiextensions-apiserver v0.31.0 // indirect k8s.io/klog/v2 v2.130.1 // indirect k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff // indirect sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect diff --git a/go.sum b/go.sum index dd99ea92..945a2757 100644 --- a/go.sum +++ b/go.sum @@ -13,8 +13,12 @@ github.com/elazarl/goproxy v0.0.0-20240726154733-8b0c20506380 h1:1NyRx2f4W4WBRyg github.com/elazarl/goproxy v0.0.0-20240726154733-8b0c20506380/go.mod h1:thX175TtLTzLj3p7N/Q9IiKZ7NF+p72cvL91emV0hzo= github.com/emicklei/go-restful/v3 v3.12.2 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf9B/a0/xU= github.com/emicklei/go-restful/v3 v3.12.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= +github.com/evanphx/json-patch v5.6.0+incompatible h1:jBYDEEiFBPxA0v50tFdvOzQQTCvpL6mnFh5mB2/l16U= +github.com/evanphx/json-patch v5.6.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= github.com/evanphx/json-patch/v5 v5.9.0 h1:kcBlZQbplgElYIlo/n1hJbls2z/1awpXxpRi0/FOJfg= github.com/evanphx/json-patch/v5 v5.9.0/go.mod h1:VNkHZ/282BpEyt/tObQO8s5CMPmYYq14uClGH4abBuQ= +github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= +github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= github.com/fxamacker/cbor/v2 v2.8.0 h1:fFtUGXUzXPHTIUdne5+zzMPTfffl3RD5qYnkY40vtxU= github.com/fxamacker/cbor/v2 v2.8.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= @@ -171,6 +175,8 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gomodules.xyz/jsonpatch/v2 v2.4.0 h1:Ci3iUJyx9UeRx7CeFN8ARgGbkESwJK+KB9lLcWxY/Zw= +gomodules.xyz/jsonpatch/v2 v2.4.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/pkg/controller/configmap_reconciler.go b/internal/pkg/controller/configmap_reconciler.go new file mode 100644 index 00000000..2dcd3338 --- /dev/null +++ b/internal/pkg/controller/configmap_reconciler.go @@ -0,0 +1,340 @@ +package controller + +import ( + "context" + "sync" + + "github.com/go-logr/logr" + "github.com/stakater/Reloader/internal/pkg/config" + "github.com/stakater/Reloader/internal/pkg/metrics" + "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" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/errors" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/event" + "sigs.k8s.io/controller-runtime/pkg/predicate" + "sigs.k8s.io/controller-runtime/pkg/reconcile" +) + +// ConfigMapReconciler watches ConfigMaps and triggers workload reloads. +type ConfigMapReconciler struct { + client.Client + Log logr.Logger + Config *config.Config + ReloadService *reload.Service + Registry *workload.Registry + Collectors *metrics.Collectors + + // initialized tracks whether initial sync has completed. + // Used to skip create events during startup unless SyncAfterRestart is enabled. + initialized bool + initOnce sync.Once +} + +// Reconcile handles ConfigMap events and triggers workload reloads as needed. +func (r *ConfigMapReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + log := r.Log.WithValues("configmap", req.NamespacedName) + + // Mark as initialized after first reconcile (caches are synced at this point) + r.initOnce.Do(func() { + r.initialized = true + log.Info("ConfigMap controller initialized") + }) + + // Fetch the ConfigMap + var cm corev1.ConfigMap + if err := r.Get(ctx, req.NamespacedName, &cm); err != nil { + if errors.IsNotFound(err) { + // ConfigMap was deleted - handle if ReloadOnDelete is enabled + if r.Config.ReloadOnDelete { + return r.handleDelete(ctx, req, log) + } + return ctrl.Result{}, nil + } + log.Error(err, "failed to get ConfigMap") + return ctrl.Result{}, err + } + + // Check if namespace should be ignored + if r.Config.IsNamespaceIgnored(cm.Namespace) { + log.V(1).Info("skipping ConfigMap in ignored namespace") + return ctrl.Result{}, nil + } + + // Get all workloads in the same namespace + workloads, err := r.listWorkloads(ctx, cm.Namespace) + if err != nil { + log.Error(err, "failed to list workloads") + return ctrl.Result{}, err + } + + // Evaluate which workloads should be reloaded + change := reload.ConfigMapChange{ + ConfigMap: &cm, + EventType: reload.EventTypeUpdate, + } + decisions := r.ReloadService.ProcessConfigMap(change, workloads) + + // Apply reloads + for _, decision := range decisions { + if !decision.ShouldReload { + continue + } + + log.Info("reloading workload", + "workload", decision.Workload.GetName(), + "kind", decision.Workload.Kind(), + "reason", decision.Reason, + ) + + updated, err := r.ReloadService.ApplyReload( + ctx, + decision.Workload, + cm.Name, + reload.ResourceTypeConfigMap, + cm.Namespace, + decision.Hash, + decision.AutoReload, + ) + if err != nil { + log.Error(err, "failed to apply reload", + "workload", decision.Workload.GetName(), + "kind", decision.Workload.Kind(), + ) + r.recordMetrics(false, cm.Namespace) + continue + } + + if updated { + // Persist the changes + if err := r.Update(ctx, decision.Workload.GetObject()); err != nil { + log.Error(err, "failed to update workload", + "workload", decision.Workload.GetName(), + "kind", decision.Workload.Kind(), + ) + r.recordMetrics(false, cm.Namespace) + continue + } + r.recordMetrics(true, cm.Namespace) + log.Info("workload reloaded successfully", + "workload", decision.Workload.GetName(), + "kind", decision.Workload.Kind(), + ) + } + } + + return ctrl.Result{}, nil +} + +// handleDelete handles ConfigMap deletion events. +func (r *ConfigMapReconciler) handleDelete(ctx context.Context, req ctrl.Request, log logr.Logger) (ctrl.Result, error) { + log.Info("handling ConfigMap deletion") + + // Get all workloads in the namespace + workloads, err := r.listWorkloads(ctx, req.Namespace) + if err != nil { + log.Error(err, "failed to list workloads") + return ctrl.Result{}, err + } + + // For delete events, we create a change with nil ConfigMap + // The service will use an empty hash + change := reload.ConfigMapChange{ + ConfigMap: &corev1.ConfigMap{}, + EventType: reload.EventTypeDelete, + } + change.ConfigMap.Name = req.Name + change.ConfigMap.Namespace = req.Namespace + + decisions := r.ReloadService.ProcessConfigMap(change, workloads) + + // Apply reloads for delete + for _, decision := range decisions { + if !decision.ShouldReload { + continue + } + + log.Info("reloading workload due to ConfigMap deletion", + "workload", decision.Workload.GetName(), + "kind", decision.Workload.Kind(), + ) + + updated, err := r.ReloadService.ApplyReload( + ctx, + decision.Workload, + req.Name, + reload.ResourceTypeConfigMap, + req.Namespace, + decision.Hash, + decision.AutoReload, + ) + if err != nil { + log.Error(err, "failed to apply reload for deletion") + r.recordMetrics(false, req.Namespace) + continue + } + + if updated { + if err := r.Update(ctx, decision.Workload.GetObject()); err != nil { + log.Error(err, "failed to update workload") + r.recordMetrics(false, req.Namespace) + continue + } + r.recordMetrics(true, req.Namespace) + } + } + + return ctrl.Result{}, nil +} + +// listWorkloads returns all workloads in the given namespace. +func (r *ConfigMapReconciler) listWorkloads(ctx context.Context, namespace string) ([]workload.WorkloadAccessor, error) { + var result []workload.WorkloadAccessor + + for _, kind := range r.Registry.SupportedKinds() { + // Skip ignored workload types + if r.Config.IsWorkloadIgnored(string(kind)) { + continue + } + + workloads, err := r.listWorkloadsByKind(ctx, namespace, kind) + if err != nil { + return nil, err + } + result = append(result, workloads...) + } + + return result, nil +} + +// listWorkloadsByKind lists workloads of a specific kind in the namespace. +func (r *ConfigMapReconciler) listWorkloadsByKind(ctx context.Context, namespace string, kind workload.Kind) ([]workload.WorkloadAccessor, error) { + switch kind { + case workload.KindDeployment: + return r.listDeployments(ctx, namespace) + case workload.KindDaemonSet: + return r.listDaemonSets(ctx, namespace) + case workload.KindStatefulSet: + return r.listStatefulSets(ctx, namespace) + case workload.KindJob: + return r.listJobs(ctx, namespace) + case workload.KindCronJob: + return r.listCronJobs(ctx, namespace) + default: + return nil, nil + } +} + +func (r *ConfigMapReconciler) listDeployments(ctx context.Context, namespace string) ([]workload.WorkloadAccessor, error) { + var list appsv1.DeploymentList + if err := r.List(ctx, &list, client.InNamespace(namespace)); err != nil { + return nil, err + } + result := make([]workload.WorkloadAccessor, len(list.Items)) + for i := range list.Items { + result[i] = workload.NewDeploymentWorkload(&list.Items[i]) + } + return result, nil +} + +func (r *ConfigMapReconciler) listDaemonSets(ctx context.Context, namespace string) ([]workload.WorkloadAccessor, error) { + var list appsv1.DaemonSetList + if err := r.List(ctx, &list, client.InNamespace(namespace)); err != nil { + return nil, err + } + result := make([]workload.WorkloadAccessor, len(list.Items)) + for i := range list.Items { + result[i] = workload.NewDaemonSetWorkload(&list.Items[i]) + } + return result, nil +} + +func (r *ConfigMapReconciler) listStatefulSets(ctx context.Context, namespace string) ([]workload.WorkloadAccessor, error) { + var list appsv1.StatefulSetList + if err := r.List(ctx, &list, client.InNamespace(namespace)); err != nil { + return nil, err + } + result := make([]workload.WorkloadAccessor, len(list.Items)) + for i := range list.Items { + result[i] = workload.NewStatefulSetWorkload(&list.Items[i]) + } + return result, nil +} + +func (r *ConfigMapReconciler) listJobs(ctx context.Context, namespace string) ([]workload.WorkloadAccessor, error) { + var list batchv1.JobList + if err := r.List(ctx, &list, client.InNamespace(namespace)); err != nil { + return nil, err + } + result := make([]workload.WorkloadAccessor, len(list.Items)) + for i := range list.Items { + result[i] = workload.NewJobWorkload(&list.Items[i]) + } + return result, nil +} + +func (r *ConfigMapReconciler) listCronJobs(ctx context.Context, namespace string) ([]workload.WorkloadAccessor, error) { + var list batchv1.CronJobList + if err := r.List(ctx, &list, client.InNamespace(namespace)); err != nil { + return nil, err + } + result := make([]workload.WorkloadAccessor, len(list.Items)) + for i := range list.Items { + result[i] = workload.NewCronJobWorkload(&list.Items[i]) + } + return result, nil +} + +// recordMetrics records reload metrics. +func (r *ConfigMapReconciler) recordMetrics(success bool, namespace string) { + if r.Collectors == nil { + return + } + // TODO: Integrate with existing metrics collectors +} + +// SetupWithManager sets up the controller with the Manager. +func (r *ConfigMapReconciler) SetupWithManager(mgr ctrl.Manager) error { + hasher := r.ReloadService.Hasher() + + return ctrl.NewControllerManagedBy(mgr). + For(&corev1.ConfigMap{}). + WithEventFilter(predicate.And( + reload.ConfigMapPredicates(r.Config, hasher), + reload.NamespaceFilterPredicate(r.Config), + reload.LabelSelectorPredicate(r.Config), + reload.IgnoreAnnotationPredicate(r.Config), + r.createEventFilter(), + )). + Complete(r) +} + +// createEventFilter filters create events based on initialization state. +func (r *ConfigMapReconciler) createEventFilter() predicate.Predicate { + return predicate.Funcs{ + CreateFunc: func(e event.CreateEvent) bool { + // During startup, skip create events unless SyncAfterRestart is enabled + if !r.initialized && !r.Config.SyncAfterRestart { + return false + } + // After initialization, only process creates if ReloadOnCreate is enabled + return r.Config.ReloadOnCreate + }, + UpdateFunc: func(e event.UpdateEvent) bool { + return true + }, + DeleteFunc: func(e event.DeleteEvent) bool { + return r.Config.ReloadOnDelete + }, + GenericFunc: func(e event.GenericEvent) bool { + return false + }, + } +} + +// Ensure ConfigMapReconciler implements reconcile.Reconciler +var _ reconcile.Reconciler = &ConfigMapReconciler{} diff --git a/internal/pkg/controller/secret_reconciler.go b/internal/pkg/controller/secret_reconciler.go new file mode 100644 index 00000000..54e4aa8e --- /dev/null +++ b/internal/pkg/controller/secret_reconciler.go @@ -0,0 +1,340 @@ +package controller + +import ( + "context" + "sync" + + "github.com/go-logr/logr" + "github.com/stakater/Reloader/internal/pkg/config" + "github.com/stakater/Reloader/internal/pkg/metrics" + "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" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/errors" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/event" + "sigs.k8s.io/controller-runtime/pkg/predicate" + "sigs.k8s.io/controller-runtime/pkg/reconcile" +) + +// SecretReconciler watches Secrets and triggers workload reloads. +type SecretReconciler struct { + client.Client + Log logr.Logger + Config *config.Config + ReloadService *reload.Service + Registry *workload.Registry + Collectors *metrics.Collectors + + // initialized tracks whether initial sync has completed. + // Used to skip create events during startup unless SyncAfterRestart is enabled. + initialized bool + initOnce sync.Once +} + +// Reconcile handles Secret events and triggers workload reloads as needed. +func (r *SecretReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + log := r.Log.WithValues("secret", req.NamespacedName) + + // Mark as initialized after first reconcile (caches are synced at this point) + r.initOnce.Do(func() { + r.initialized = true + log.Info("Secret controller initialized") + }) + + // Fetch the Secret + var secret corev1.Secret + if err := r.Get(ctx, req.NamespacedName, &secret); err != nil { + if errors.IsNotFound(err) { + // Secret was deleted - handle if ReloadOnDelete is enabled + if r.Config.ReloadOnDelete { + return r.handleDelete(ctx, req, log) + } + return ctrl.Result{}, nil + } + log.Error(err, "failed to get Secret") + return ctrl.Result{}, err + } + + // Check if namespace should be ignored + if r.Config.IsNamespaceIgnored(secret.Namespace) { + log.V(1).Info("skipping Secret in ignored namespace") + return ctrl.Result{}, nil + } + + // Get all workloads in the same namespace + workloads, err := r.listWorkloads(ctx, secret.Namespace) + if err != nil { + log.Error(err, "failed to list workloads") + return ctrl.Result{}, err + } + + // Evaluate which workloads should be reloaded + change := reload.SecretChange{ + Secret: &secret, + EventType: reload.EventTypeUpdate, + } + decisions := r.ReloadService.ProcessSecret(change, workloads) + + // Apply reloads + for _, decision := range decisions { + if !decision.ShouldReload { + continue + } + + log.Info("reloading workload", + "workload", decision.Workload.GetName(), + "kind", decision.Workload.Kind(), + "reason", decision.Reason, + ) + + updated, err := r.ReloadService.ApplyReload( + ctx, + decision.Workload, + secret.Name, + reload.ResourceTypeSecret, + secret.Namespace, + decision.Hash, + decision.AutoReload, + ) + if err != nil { + log.Error(err, "failed to apply reload", + "workload", decision.Workload.GetName(), + "kind", decision.Workload.Kind(), + ) + r.recordMetrics(false, secret.Namespace) + continue + } + + if updated { + // Persist the changes + if err := r.Update(ctx, decision.Workload.GetObject()); err != nil { + log.Error(err, "failed to update workload", + "workload", decision.Workload.GetName(), + "kind", decision.Workload.Kind(), + ) + r.recordMetrics(false, secret.Namespace) + continue + } + r.recordMetrics(true, secret.Namespace) + log.Info("workload reloaded successfully", + "workload", decision.Workload.GetName(), + "kind", decision.Workload.Kind(), + ) + } + } + + return ctrl.Result{}, nil +} + +// handleDelete handles Secret deletion events. +func (r *SecretReconciler) handleDelete(ctx context.Context, req ctrl.Request, log logr.Logger) (ctrl.Result, error) { + log.Info("handling Secret deletion") + + // Get all workloads in the namespace + workloads, err := r.listWorkloads(ctx, req.Namespace) + if err != nil { + log.Error(err, "failed to list workloads") + return ctrl.Result{}, err + } + + // For delete events, we create a change with empty Secret + // The service will use an empty hash + change := reload.SecretChange{ + Secret: &corev1.Secret{}, + EventType: reload.EventTypeDelete, + } + change.Secret.Name = req.Name + change.Secret.Namespace = req.Namespace + + decisions := r.ReloadService.ProcessSecret(change, workloads) + + // Apply reloads for delete + for _, decision := range decisions { + if !decision.ShouldReload { + continue + } + + log.Info("reloading workload due to Secret deletion", + "workload", decision.Workload.GetName(), + "kind", decision.Workload.Kind(), + ) + + updated, err := r.ReloadService.ApplyReload( + ctx, + decision.Workload, + req.Name, + reload.ResourceTypeSecret, + req.Namespace, + decision.Hash, + decision.AutoReload, + ) + if err != nil { + log.Error(err, "failed to apply reload for deletion") + r.recordMetrics(false, req.Namespace) + continue + } + + if updated { + if err := r.Update(ctx, decision.Workload.GetObject()); err != nil { + log.Error(err, "failed to update workload") + r.recordMetrics(false, req.Namespace) + continue + } + r.recordMetrics(true, req.Namespace) + } + } + + return ctrl.Result{}, nil +} + +// listWorkloads returns all workloads in the given namespace. +func (r *SecretReconciler) listWorkloads(ctx context.Context, namespace string) ([]workload.WorkloadAccessor, error) { + var result []workload.WorkloadAccessor + + for _, kind := range r.Registry.SupportedKinds() { + // Skip ignored workload types + if r.Config.IsWorkloadIgnored(string(kind)) { + continue + } + + workloads, err := r.listWorkloadsByKind(ctx, namespace, kind) + if err != nil { + return nil, err + } + result = append(result, workloads...) + } + + return result, nil +} + +// listWorkloadsByKind lists workloads of a specific kind in the namespace. +func (r *SecretReconciler) listWorkloadsByKind(ctx context.Context, namespace string, kind workload.Kind) ([]workload.WorkloadAccessor, error) { + switch kind { + case workload.KindDeployment: + return r.listDeployments(ctx, namespace) + case workload.KindDaemonSet: + return r.listDaemonSets(ctx, namespace) + case workload.KindStatefulSet: + return r.listStatefulSets(ctx, namespace) + case workload.KindJob: + return r.listJobs(ctx, namespace) + case workload.KindCronJob: + return r.listCronJobs(ctx, namespace) + default: + return nil, nil + } +} + +func (r *SecretReconciler) listDeployments(ctx context.Context, namespace string) ([]workload.WorkloadAccessor, error) { + var list appsv1.DeploymentList + if err := r.List(ctx, &list, client.InNamespace(namespace)); err != nil { + return nil, err + } + result := make([]workload.WorkloadAccessor, len(list.Items)) + for i := range list.Items { + result[i] = workload.NewDeploymentWorkload(&list.Items[i]) + } + return result, nil +} + +func (r *SecretReconciler) listDaemonSets(ctx context.Context, namespace string) ([]workload.WorkloadAccessor, error) { + var list appsv1.DaemonSetList + if err := r.List(ctx, &list, client.InNamespace(namespace)); err != nil { + return nil, err + } + result := make([]workload.WorkloadAccessor, len(list.Items)) + for i := range list.Items { + result[i] = workload.NewDaemonSetWorkload(&list.Items[i]) + } + return result, nil +} + +func (r *SecretReconciler) listStatefulSets(ctx context.Context, namespace string) ([]workload.WorkloadAccessor, error) { + var list appsv1.StatefulSetList + if err := r.List(ctx, &list, client.InNamespace(namespace)); err != nil { + return nil, err + } + result := make([]workload.WorkloadAccessor, len(list.Items)) + for i := range list.Items { + result[i] = workload.NewStatefulSetWorkload(&list.Items[i]) + } + return result, nil +} + +func (r *SecretReconciler) listJobs(ctx context.Context, namespace string) ([]workload.WorkloadAccessor, error) { + var list batchv1.JobList + if err := r.List(ctx, &list, client.InNamespace(namespace)); err != nil { + return nil, err + } + result := make([]workload.WorkloadAccessor, len(list.Items)) + for i := range list.Items { + result[i] = workload.NewJobWorkload(&list.Items[i]) + } + return result, nil +} + +func (r *SecretReconciler) listCronJobs(ctx context.Context, namespace string) ([]workload.WorkloadAccessor, error) { + var list batchv1.CronJobList + if err := r.List(ctx, &list, client.InNamespace(namespace)); err != nil { + return nil, err + } + result := make([]workload.WorkloadAccessor, len(list.Items)) + for i := range list.Items { + result[i] = workload.NewCronJobWorkload(&list.Items[i]) + } + return result, nil +} + +// recordMetrics records reload metrics. +func (r *SecretReconciler) recordMetrics(success bool, namespace string) { + if r.Collectors == nil { + return + } + // TODO: Integrate with existing metrics collectors +} + +// SetupWithManager sets up the controller with the Manager. +func (r *SecretReconciler) SetupWithManager(mgr ctrl.Manager) error { + hasher := r.ReloadService.Hasher() + + return ctrl.NewControllerManagedBy(mgr). + For(&corev1.Secret{}). + WithEventFilter(predicate.And( + reload.SecretPredicates(r.Config, hasher), + reload.NamespaceFilterPredicate(r.Config), + reload.LabelSelectorPredicate(r.Config), + reload.IgnoreAnnotationPredicate(r.Config), + r.createEventFilter(), + )). + Complete(r) +} + +// createEventFilter filters create events based on initialization state. +func (r *SecretReconciler) createEventFilter() predicate.Predicate { + return predicate.Funcs{ + CreateFunc: func(e event.CreateEvent) bool { + // During startup, skip create events unless SyncAfterRestart is enabled + if !r.initialized && !r.Config.SyncAfterRestart { + return false + } + // After initialization, only process creates if ReloadOnCreate is enabled + return r.Config.ReloadOnCreate + }, + UpdateFunc: func(e event.UpdateEvent) bool { + return true + }, + DeleteFunc: func(e event.DeleteEvent) bool { + return r.Config.ReloadOnDelete + }, + GenericFunc: func(e event.GenericEvent) bool { + return false + }, + } +} + +// Ensure SecretReconciler implements reconcile.Reconciler +var _ reconcile.Reconciler = &SecretReconciler{} diff --git a/internal/pkg/reload/hasher_test.go b/internal/pkg/reload/hasher_test.go new file mode 100644 index 00000000..0b892adc --- /dev/null +++ b/internal/pkg/reload/hasher_test.go @@ -0,0 +1,231 @@ +package reload + +import ( + "testing" + + corev1 "k8s.io/api/core/v1" +) + +func TestHasher_HashConfigMap(t *testing.T) { + hasher := NewHasher() + + tests := []struct { + name string + cm *corev1.ConfigMap + wantHash string + }{ + { + name: "empty configmap", + cm: &corev1.ConfigMap{ + Data: nil, + BinaryData: nil, + }, + wantHash: hasher.EmptyHash(), + }, + { + name: "configmap with data", + cm: &corev1.ConfigMap{ + Data: map[string]string{ + "key1": "value1", + "key2": "value2", + }, + }, + // Hash should be deterministic + wantHash: hasher.HashConfigMap(&corev1.ConfigMap{ + Data: map[string]string{ + "key1": "value1", + "key2": "value2", + }, + }), + }, + { + name: "configmap with binary data", + cm: &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) + } + }) + } +} + +func TestHasher_HashConfigMap_Deterministic(t *testing.T) { + hasher := NewHasher() + + cm := &corev1.ConfigMap{ + Data: map[string]string{ + "z-key": "value-z", + "a-key": "value-a", + "m-key": "value-m", + }, + } + + // Hash should be the same regardless of iteration order + hash1 := hasher.HashConfigMap(cm) + hash2 := hasher.HashConfigMap(cm) + hash3 := hasher.HashConfigMap(cm) + + if hash1 != hash2 || hash2 != hash3 { + t.Errorf("Hash is not deterministic: %s, %s, %s", hash1, hash2, hash3) + } +} + +func TestHasher_HashConfigMap_DifferentValues(t *testing.T) { + hasher := NewHasher() + + cm1 := &corev1.ConfigMap{ + Data: map[string]string{ + "key": "value1", + }, + } + + cm2 := &corev1.ConfigMap{ + Data: map[string]string{ + "key": "value2", + }, + } + + hash1 := hasher.HashConfigMap(cm1) + hash2 := hasher.HashConfigMap(cm2) + + if hash1 == hash2 { + t.Errorf("Different values should produce different hashes") + } +} + +func TestHasher_HashSecret(t *testing.T) { + hasher := NewHasher() + + tests := []struct { + name string + secret *corev1.Secret + wantHash string + }{ + { + name: "empty secret", + secret: &corev1.Secret{ + Data: nil, + }, + wantHash: hasher.EmptyHash(), + }, + { + name: "secret with data", + secret: &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) + } + }) + } +} + +func TestHasher_HashSecret_Deterministic(t *testing.T) { + hasher := NewHasher() + + secret := &corev1.Secret{ + Data: map[string][]byte{ + "z-key": []byte("value-z"), + "a-key": []byte("value-a"), + "m-key": []byte("value-m"), + }, + } + + // Hash should be the same regardless of iteration order + hash1 := hasher.HashSecret(secret) + hash2 := hasher.HashSecret(secret) + hash3 := hasher.HashSecret(secret) + + if hash1 != hash2 || hash2 != hash3 { + t.Errorf("Hash is not deterministic: %s, %s, %s", hash1, hash2, hash3) + } +} + +func TestHasher_HashSecret_DifferentValues(t *testing.T) { + hasher := NewHasher() + + secret1 := &corev1.Secret{ + Data: map[string][]byte{ + "key": []byte("value1"), + }, + } + + secret2 := &corev1.Secret{ + Data: map[string][]byte{ + "key": []byte("value2"), + }, + } + + hash1 := hasher.HashSecret(secret1) + hash2 := hasher.HashSecret(secret2) + + if hash1 == hash2 { + t.Errorf("Different values should produce different hashes") + } +} + +func TestHasher_EmptyHash(t *testing.T) { + hasher := NewHasher() + + emptyHash := hasher.EmptyHash() + if emptyHash == "" { + t.Error("EmptyHash should not be empty string") + } + + // Empty ConfigMap should match EmptyHash + cm := &corev1.ConfigMap{} + if hasher.HashConfigMap(cm) != emptyHash { + t.Error("Empty ConfigMap hash should equal EmptyHash") + } + + // Empty Secret should match EmptyHash + secret := &corev1.Secret{} + if hasher.HashSecret(secret) != emptyHash { + t.Error("Empty Secret hash should equal EmptyHash") + } +} + +func TestHasher_NilInput(t *testing.T) { + hasher := NewHasher() + + // Test nil ConfigMap + cmHash := hasher.HashConfigMap(nil) + if cmHash != hasher.EmptyHash() { + t.Errorf("nil ConfigMap should return EmptyHash, got %s", cmHash) + } + + // Test nil Secret + secretHash := hasher.HashSecret(nil) + if secretHash != hasher.EmptyHash() { + t.Errorf("nil Secret should return EmptyHash, got %s", secretHash) + } +} diff --git a/internal/pkg/reload/matcher_test.go b/internal/pkg/reload/matcher_test.go new file mode 100644 index 00000000..5e4f8b8f --- /dev/null +++ b/internal/pkg/reload/matcher_test.go @@ -0,0 +1,488 @@ +package reload + +import ( + "testing" + + "github.com/stakater/Reloader/internal/pkg/config" +) + +func TestMatcher_ShouldReload(t *testing.T) { + defaultCfg := config.NewDefault() + matcher := NewMatcher(defaultCfg) + + tests := []struct { + name string + input MatchInput + wantReload bool + wantAutoReload bool + description string + }{ + // Ignore annotation tests + { + name: "ignore annotation on resource skips reload", + input: MatchInput{ + ResourceName: "my-config", + ResourceNamespace: "default", + ResourceType: ResourceTypeConfigMap, + ResourceAnnotations: map[string]string{"reloader.stakater.com/ignore": "true"}, + WorkloadAnnotations: map[string]string{"reloader.stakater.com/auto": "true"}, + PodAnnotations: nil, + }, + wantReload: false, + wantAutoReload: false, + description: "Resources with ignore annotation should never trigger reload", + }, + { + name: "ignore annotation false allows reload", + input: MatchInput{ + ResourceName: "my-config", + ResourceNamespace: "default", + ResourceType: ResourceTypeConfigMap, + ResourceAnnotations: map[string]string{"reloader.stakater.com/ignore": "false"}, + WorkloadAnnotations: map[string]string{"reloader.stakater.com/auto": "true"}, + PodAnnotations: nil, + }, + wantReload: true, + wantAutoReload: true, + description: "Resources with ignore=false should allow reload", + }, + + // Exclude annotation tests + { + name: "exclude annotation skips reload", + input: MatchInput{ + ResourceName: "my-config", + ResourceNamespace: "default", + ResourceType: ResourceTypeConfigMap, + ResourceAnnotations: nil, + WorkloadAnnotations: map[string]string{ + "reloader.stakater.com/auto": "true", + "configmaps.exclude.reloader.stakater.com/reload": "my-config", + }, + PodAnnotations: nil, + }, + wantReload: false, + wantAutoReload: false, + description: "Excluded ConfigMaps should not trigger reload", + }, + { + name: "exclude annotation with multiple values", + input: MatchInput{ + ResourceName: "my-config", + ResourceNamespace: "default", + ResourceType: ResourceTypeConfigMap, + ResourceAnnotations: nil, + WorkloadAnnotations: map[string]string{ + "reloader.stakater.com/auto": "true", + "configmaps.exclude.reloader.stakater.com/reload": "other-config,my-config,another-config", + }, + PodAnnotations: nil, + }, + wantReload: false, + 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{ + ResourceName: "external-config", + ResourceNamespace: "default", + ResourceType: ResourceTypeConfigMap, + ResourceAnnotations: nil, + WorkloadAnnotations: map[string]string{ + "reloader.stakater.com/auto": "true", + "configmap.reloader.stakater.com/reload": "external-config", + }, + PodAnnotations: nil, + }, + wantReload: true, + wantAutoReload: false, // Explicit, not auto + description: "BUG FIX: Explicit reload annotation should work even when auto is enabled", + }, + { + name: "explicit reload annotation matches pattern - should reload", + input: MatchInput{ + ResourceName: "app-config-v2", + ResourceNamespace: "default", + ResourceType: ResourceTypeConfigMap, + ResourceAnnotations: nil, + WorkloadAnnotations: map[string]string{ + "configmap.reloader.stakater.com/reload": "app-config-.*", + }, + PodAnnotations: nil, + }, + wantReload: true, + wantAutoReload: false, + description: "Regex pattern in reload annotation should match", + }, + { + name: "explicit reload annotation does not match - should not reload", + input: MatchInput{ + ResourceName: "other-config", + ResourceNamespace: "default", + ResourceType: ResourceTypeConfigMap, + ResourceAnnotations: nil, + WorkloadAnnotations: map[string]string{ + "configmap.reloader.stakater.com/reload": "app-config", + }, + PodAnnotations: nil, + }, + wantReload: false, + wantAutoReload: false, + description: "ConfigMaps not in reload list should not trigger reload", + }, + + // Auto annotation tests + { + name: "auto annotation on workload triggers reload", + input: MatchInput{ + ResourceName: "my-config", + ResourceNamespace: "default", + ResourceType: ResourceTypeConfigMap, + ResourceAnnotations: nil, + WorkloadAnnotations: map[string]string{"reloader.stakater.com/auto": "true"}, + PodAnnotations: nil, + }, + wantReload: true, + wantAutoReload: true, + description: "Auto annotation on workload should trigger reload", + }, + { + name: "auto annotation on pod template triggers reload", + input: MatchInput{ + ResourceName: "my-config", + ResourceNamespace: "default", + ResourceType: ResourceTypeConfigMap, + ResourceAnnotations: nil, + WorkloadAnnotations: nil, + PodAnnotations: map[string]string{"reloader.stakater.com/auto": "true"}, + }, + wantReload: true, + wantAutoReload: true, + description: "Auto annotation on pod template should trigger reload", + }, + { + name: "configmap-specific auto annotation", + input: MatchInput{ + ResourceName: "my-config", + ResourceNamespace: "default", + ResourceType: ResourceTypeConfigMap, + ResourceAnnotations: nil, + WorkloadAnnotations: map[string]string{"configmap.reloader.stakater.com/auto": "true"}, + PodAnnotations: nil, + }, + wantReload: true, + wantAutoReload: true, + description: "ConfigMap-specific auto annotation should trigger reload", + }, + { + name: "secret-specific auto annotation for secret", + input: MatchInput{ + ResourceName: "my-secret", + ResourceNamespace: "default", + ResourceType: ResourceTypeSecret, + ResourceAnnotations: nil, + WorkloadAnnotations: map[string]string{"secret.reloader.stakater.com/auto": "true"}, + PodAnnotations: nil, + }, + wantReload: true, + wantAutoReload: true, + description: "Secret-specific auto annotation should trigger reload for secrets", + }, + { + name: "configmap-specific auto annotation does not match secret", + input: MatchInput{ + ResourceName: "my-secret", + ResourceNamespace: "default", + ResourceType: ResourceTypeSecret, + ResourceAnnotations: nil, + WorkloadAnnotations: map[string]string{"configmap.reloader.stakater.com/auto": "true"}, + PodAnnotations: nil, + }, + wantReload: false, + wantAutoReload: false, + description: "ConfigMap-specific auto annotation should not match secrets", + }, + + // Search/Match annotation tests + { + name: "search annotation with matching resource", + input: MatchInput{ + ResourceName: "app-config", + ResourceNamespace: "default", + ResourceType: ResourceTypeConfigMap, + ResourceAnnotations: map[string]string{"reloader.stakater.com/match": "true"}, + WorkloadAnnotations: map[string]string{"reloader.stakater.com/search": "true"}, + PodAnnotations: nil, + }, + wantReload: true, + wantAutoReload: true, // Search mode is an auto-discovery mechanism + description: "Search annotation with matching resource should trigger reload", + }, + { + name: "search annotation without matching resource", + input: MatchInput{ + ResourceName: "app-config", + ResourceNamespace: "default", + ResourceType: ResourceTypeConfigMap, + ResourceAnnotations: nil, + WorkloadAnnotations: map[string]string{"reloader.stakater.com/search": "true"}, + PodAnnotations: nil, + }, + wantReload: false, + 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{ + ResourceName: "my-config", + ResourceNamespace: "default", + ResourceType: ResourceTypeConfigMap, + ResourceAnnotations: nil, + WorkloadAnnotations: nil, + PodAnnotations: nil, + }, + wantReload: false, + wantAutoReload: false, + description: "Without any annotations, should not trigger reload", + }, + + // Secret tests + { + name: "secret reload annotation", + input: MatchInput{ + ResourceName: "my-secret", + ResourceNamespace: "default", + ResourceType: ResourceTypeSecret, + ResourceAnnotations: nil, + WorkloadAnnotations: map[string]string{ + "secret.reloader.stakater.com/reload": "my-secret", + }, + PodAnnotations: nil, + }, + wantReload: true, + wantAutoReload: false, + description: "Secret reload annotation should trigger reload", + }, + { + name: "secret exclude annotation", + input: MatchInput{ + ResourceName: "my-secret", + ResourceNamespace: "default", + ResourceType: ResourceTypeSecret, + ResourceAnnotations: nil, + WorkloadAnnotations: map[string]string{ + "reloader.stakater.com/auto": "true", + "secrets.exclude.reloader.stakater.com/reload": "my-secret", + }, + PodAnnotations: nil, + }, + wantReload: false, + wantAutoReload: false, + description: "Secret exclude annotation should prevent reload", + }, + } + + for _, tt := range tests { + 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.AutoReload != tt.wantAutoReload { + t.Errorf("AutoReload = %v, want %v (%s)", result.AutoReload, tt.wantAutoReload, tt.description) + } + + t.Logf("✓ %s", tt.description) + }) + } +} + +func TestMatcher_ShouldReload_AutoReloadAll(t *testing.T) { + cfg := config.NewDefault() + cfg.AutoReloadAll = true + matcher := NewMatcher(cfg) + + tests := []struct { + name string + input MatchInput + wantReload bool + wantAutoReload bool + description string + }{ + { + name: "auto-reload-all triggers reload", + input: MatchInput{ + ResourceName: "my-config", + ResourceNamespace: "default", + ResourceType: ResourceTypeConfigMap, + ResourceAnnotations: nil, + WorkloadAnnotations: nil, + PodAnnotations: nil, + }, + wantReload: true, + wantAutoReload: true, + description: "With auto-reload-all enabled, all ConfigMaps should trigger reload", + }, + { + name: "auto-reload-all respects ignore annotation", + input: MatchInput{ + ResourceName: "my-config", + ResourceNamespace: "default", + ResourceType: ResourceTypeConfigMap, + ResourceAnnotations: map[string]string{"reloader.stakater.com/ignore": "true"}, + WorkloadAnnotations: nil, + PodAnnotations: nil, + }, + wantReload: false, + wantAutoReload: false, + description: "Even with auto-reload-all, ignore annotation should be respected", + }, + { + name: "auto-reload-all respects exclude annotation", + input: MatchInput{ + ResourceName: "my-config", + ResourceNamespace: "default", + ResourceType: ResourceTypeConfigMap, + ResourceAnnotations: nil, + WorkloadAnnotations: map[string]string{ + "configmaps.exclude.reloader.stakater.com/reload": "my-config", + }, + PodAnnotations: nil, + }, + wantReload: false, + wantAutoReload: false, + description: "Even with auto-reload-all, exclude annotation should be respected", + }, + } + + for _, tt := range tests { + 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.AutoReload != tt.wantAutoReload { + t.Errorf("AutoReload = %v, want %v (%s)", result.AutoReload, tt.wantAutoReload, tt.description) + } + + t.Logf("✓ %s", tt.description) + }) + } +} + +// TestMatcher_BugFix_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) { + 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", + ResourceType: ResourceTypeConfigMap, + ResourceAnnotations: nil, + WorkloadAnnotations: map[string]string{ + "reloader.stakater.com/auto": "true", // Enables auto-reload + "configmap.reloader.stakater.com/reload": "external-config", // Explicit list + }, + PodAnnotations: nil, + } + + result := matcher.ShouldReload(input) + + if !result.ShouldReload { + t.Errorf("BUG: Explicit reload annotation ignored when auto is enabled") + 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") +} + +// TestMatcher_PrecedenceOrder verifies the correct order of precedence: +// 1. Ignore annotation → skip +// 2. Exclude annotation → skip +// 3. Explicit reload annotation → reload (BUG FIX: before auto!) +// 4. Search/Match → reload +// 5. Auto annotation → reload +// 6. Auto-reload-all → reload +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("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") + } + }) +} diff --git a/internal/pkg/reload/strategy_test.go b/internal/pkg/reload/strategy_test.go new file mode 100644 index 00000000..41c54538 --- /dev/null +++ b/internal/pkg/reload/strategy_test.go @@ -0,0 +1,292 @@ +package reload + +import ( + "encoding/json" + "testing" + + "github.com/stakater/Reloader/internal/pkg/config" + corev1 "k8s.io/api/core/v1" +) + +func TestEnvVarStrategy_Apply(t *testing.T) { + strategy := NewEnvVarStrategy() + + t.Run("adds new env var", func(t *testing.T) { + container := &corev1.Container{ + Name: "test-container", + Env: []corev1.EnvVar{}, + } + + input := StrategyInput{ + ResourceName: "my-config", + ResourceType: ResourceTypeConfigMap, + Namespace: "default", + Hash: "abc123", + Container: container, + } + + changed, err := strategy.Apply(input) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !changed { + t.Error("expected changed=true for new env var") + } + + // Verify env var was added + found := false + for _, env := range container.Env { + if env.Name == "STAKATER_MY_CONFIG_CONFIGMAP" && env.Value == "abc123" { + found = true + break + } + } + if !found { + t.Errorf("expected env var STAKATER_MY_CONFIG_CONFIGMAP=abc123, got %+v", container.Env) + } + }) + + t.Run("updates existing env var", func(t *testing.T) { + container := &corev1.Container{ + Name: "test-container", + Env: []corev1.EnvVar{ + {Name: "STAKATER_MY_CONFIG_CONFIGMAP", Value: "old-hash"}, + }, + } + + input := StrategyInput{ + ResourceName: "my-config", + ResourceType: ResourceTypeConfigMap, + Namespace: "default", + Hash: "new-hash", + Container: container, + } + + changed, err := strategy.Apply(input) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !changed { + t.Error("expected changed=true for updated env var") + } + + // Verify env var was updated + if container.Env[0].Value != "new-hash" { + t.Errorf("expected env var value=new-hash, got %s", container.Env[0].Value) + } + }) + + t.Run("no change when hash is same", func(t *testing.T) { + container := &corev1.Container{ + Name: "test-container", + Env: []corev1.EnvVar{ + {Name: "STAKATER_MY_CONFIG_CONFIGMAP", Value: "same-hash"}, + }, + } + + input := StrategyInput{ + ResourceName: "my-config", + ResourceType: ResourceTypeConfigMap, + Namespace: "default", + Hash: "same-hash", + Container: container, + } + + changed, err := strategy.Apply(input) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if changed { + t.Error("expected changed=false when hash is unchanged") + } + }) + + t.Run("error when container is nil", func(t *testing.T) { + input := StrategyInput{ + ResourceName: "my-config", + ResourceType: ResourceTypeConfigMap, + Namespace: "default", + Hash: "abc123", + Container: nil, + } + + _, err := strategy.Apply(input) + if err == nil { + t.Error("expected error for nil container") + } + }) + + t.Run("secret env var has correct postfix", func(t *testing.T) { + container := &corev1.Container{ + Name: "test-container", + Env: []corev1.EnvVar{}, + } + + input := StrategyInput{ + ResourceName: "my-secret", + ResourceType: ResourceTypeSecret, + Namespace: "default", + Hash: "abc123", + Container: container, + } + + changed, err := strategy.Apply(input) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !changed { + t.Error("expected changed=true") + } + + // Verify env var name has SECRET postfix + found := false + for _, env := range container.Env { + if env.Name == "STAKATER_MY_SECRET_SECRET" && env.Value == "abc123" { + found = true + break + } + } + if !found { + t.Errorf("expected env var STAKATER_MY_SECRET_SECRET=abc123, got %+v", container.Env) + } + }) +} + +func TestEnvVarStrategy_EnvVarName(t *testing.T) { + strategy := NewEnvVarStrategy() + + tests := []struct { + resourceName string + resourceType ResourceType + expected string + }{ + {"my-config", ResourceTypeConfigMap, "STAKATER_MY_CONFIG_CONFIGMAP"}, + {"my-secret", ResourceTypeSecret, "STAKATER_MY_SECRET_SECRET"}, + {"app-config-v2", ResourceTypeConfigMap, "STAKATER_APP_CONFIG_V2_CONFIGMAP"}, + {"my.dotted.config", ResourceTypeConfigMap, "STAKATER_MY_DOTTED_CONFIG_CONFIGMAP"}, + {"MyMixedCase", ResourceTypeConfigMap, "STAKATER_MYMIXEDCASE_CONFIGMAP"}, + {"config-with-123-numbers", ResourceTypeConfigMap, "STAKATER_CONFIG_WITH_123_NUMBERS_CONFIGMAP"}, + } + + for _, tt := range tests { + t.Run(tt.resourceName, func(t *testing.T) { + got := strategy.envVarName(tt.resourceName, tt.resourceType) + if got != tt.expected { + t.Errorf("envVarName(%q, %q) = %q, want %q", + tt.resourceName, tt.resourceType, got, tt.expected) + } + }) + } +} + +func TestConvertToEnvVarName(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"my-config", "MY_CONFIG"}, + {"my.config", "MY_CONFIG"}, + {"my_config", "MY_CONFIG"}, + {"MY-CONFIG", "MY_CONFIG"}, + {"config123", "CONFIG123"}, + {"123config", "123CONFIG"}, + {"my--config", "MY_CONFIG"}, + {"my..config", "MY_CONFIG"}, + {"", ""}, + {"-leading-dash", "LEADING_DASH"}, + {"trailing-dash-", "TRAILING_DASH_"}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + got := convertToEnvVarName(tt.input) + if got != tt.expected { + t.Errorf("convertToEnvVarName(%q) = %q, want %q", tt.input, got, tt.expected) + } + }) + } +} + +func TestAnnotationStrategy_Apply(t *testing.T) { + cfg := config.NewDefault() + strategy := NewAnnotationStrategy(cfg) + + t.Run("adds new annotation", func(t *testing.T) { + annotations := make(map[string]string) + container := &corev1.Container{Name: "test-container"} + + input := StrategyInput{ + ResourceName: "my-config", + ResourceType: ResourceTypeConfigMap, + Namespace: "default", + Hash: "abc123", + Container: container, + PodAnnotations: annotations, + } + + changed, err := strategy.Apply(input) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !changed { + t.Error("expected changed=true for new annotation") + } + + // Verify annotation was added + annotationValue := annotations[cfg.Annotations.LastReloadedFrom] + if annotationValue == "" { + t.Error("expected annotation to be set") + } + + // Verify annotation content + var source ReloadSource + if err := json.Unmarshal([]byte(annotationValue), &source); err != nil { + t.Fatalf("failed to unmarshal annotation: %v", err) + } + if source.Kind != string(ResourceTypeConfigMap) { + t.Errorf("expected kind=%s, got %s", ResourceTypeConfigMap, source.Kind) + } + if source.Name != "my-config" { + t.Errorf("expected name=my-config, got %s", source.Name) + } + if source.Hash != "abc123" { + t.Errorf("expected hash=abc123, got %s", source.Hash) + } + }) + + t.Run("error when annotations map is nil", func(t *testing.T) { + input := StrategyInput{ + ResourceName: "my-config", + ResourceType: ResourceTypeConfigMap, + Namespace: "default", + Hash: "abc123", + PodAnnotations: nil, + } + + _, err := strategy.Apply(input) + if err == nil { + t.Error("expected error for nil annotations map") + } + }) +} + +func TestNewStrategy(t *testing.T) { + t.Run("default strategy is env-vars", func(t *testing.T) { + cfg := config.NewDefault() + strategy := NewStrategy(cfg) + + if strategy.Name() != string(config.ReloadStrategyEnvVars) { + t.Errorf("expected env-vars strategy, got %s", strategy.Name()) + } + }) + + t.Run("annotations strategy when configured", func(t *testing.T) { + cfg := config.NewDefault() + cfg.ReloadStrategy = config.ReloadStrategyAnnotations + strategy := NewStrategy(cfg) + + if strategy.Name() != string(config.ReloadStrategyAnnotations) { + t.Errorf("expected annotations strategy, got %s", strategy.Name()) + } + }) +}