mirror of
https://github.com/stakater/Reloader.git
synced 2026-08-20 12:36:26 +00:00
feat: Add controller-runtime reconcilers and unit tests for reload packag
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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{}
|
||||
@@ -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{}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user