feat: Add controller-runtime reconcilers and unit tests for reload packag

This commit is contained in:
TheiLLeniumStudios
2025-12-28 08:47:52 +01:00
parent 94e3fcd702
commit 0a2aa122f5
7 changed files with 1702 additions and 1 deletions
+5 -1
View File
@@ -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
+6
View File
@@ -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{}
+231
View File
@@ -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)
}
}
+488
View File
@@ -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")
}
})
}
+292
View File
@@ -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())
}
})
}