feat: reload execution and observability

This commit is contained in:
TheiLLeniumStudios
2025-12-28 08:47:52 +01:00
parent ce1e7dfafb
commit b5df945fff
8 changed files with 1827 additions and 21 deletions
+3 -3
View File
@@ -78,8 +78,8 @@ func (h *Hasher) computeSHA(data string) string {
return fmt.Sprintf("%x", hasher.Sum(nil))
}
// EmptyHash returns the hash of empty content.
// This is useful for comparison when resources are deleted.
// EmptyHash returns an empty string to signal resource deletion.
// This triggers env var removal when using the env-vars strategy.
func (h *Hasher) EmptyHash() string {
return h.computeSHA("")
return ""
}
+21 -16
View File
@@ -20,7 +20,8 @@ func TestHasher_HashConfigMap(t *testing.T) {
Data: nil,
BinaryData: nil,
},
wantHash: hasher.EmptyHash(),
// Empty configmap gets a valid hash (hash of empty data)
wantHash: hasher.HashConfigMap(&corev1.ConfigMap{}),
},
{
name: "configmap with data",
@@ -120,7 +121,8 @@ func TestHasher_HashSecret(t *testing.T) {
secret: &corev1.Secret{
Data: nil,
},
wantHash: hasher.EmptyHash(),
// Empty secret gets a valid hash (hash of empty data)
wantHash: hasher.HashSecret(&corev1.Secret{}),
},
{
name: "secret with data",
@@ -196,36 +198,39 @@ func TestHasher_HashSecret_DifferentValues(t *testing.T) {
func TestHasher_EmptyHash(t *testing.T) {
hasher := NewHasher()
// EmptyHash returns empty string to signal deletion
emptyHash := hasher.EmptyHash()
if emptyHash == "" {
t.Error("EmptyHash should not be empty string")
if emptyHash != "" {
t.Errorf("EmptyHash should be empty string, got %s", emptyHash)
}
// Empty ConfigMap should match EmptyHash
// Empty ConfigMap should have a valid hash (not empty)
cm := &corev1.ConfigMap{}
if hasher.HashConfigMap(cm) != emptyHash {
t.Error("Empty ConfigMap hash should equal EmptyHash")
cmHash := hasher.HashConfigMap(cm)
if cmHash == "" {
t.Error("Empty ConfigMap should have a non-empty hash")
}
// Empty Secret should match EmptyHash
// Empty Secret should have a valid hash (not empty)
secret := &corev1.Secret{}
if hasher.HashSecret(secret) != emptyHash {
t.Error("Empty Secret hash should equal EmptyHash")
secretHash := hasher.HashSecret(secret)
if secretHash == "" {
t.Error("Empty Secret should have a non-empty hash")
}
}
func TestHasher_NilInput(t *testing.T) {
hasher := NewHasher()
// Test nil ConfigMap
// Test nil ConfigMap - returns hash of empty content (not EmptyHash)
cmHash := hasher.HashConfigMap(nil)
if cmHash != hasher.EmptyHash() {
t.Errorf("nil ConfigMap should return EmptyHash, got %s", cmHash)
if cmHash == "" {
t.Error("nil ConfigMap should return a valid hash")
}
// Test nil Secret
// Test nil Secret - returns hash of empty content (not EmptyHash)
secretHash := hasher.HashSecret(nil)
if secretHash != hasher.EmptyHash() {
t.Errorf("nil Secret should return EmptyHash, got %s", secretHash)
if secretHash == "" {
t.Error("nil Secret should return a valid hash")
}
}
+502
View File
@@ -0,0 +1,502 @@
package reload
import (
"testing"
"github.com/stakater/Reloader/internal/pkg/config"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/labels"
"sigs.k8s.io/controller-runtime/pkg/event"
)
func TestNamespaceFilterPredicate_Create(t *testing.T) {
tests := []struct {
name string
ignoredNamespaces []string
eventNamespace string
wantAllow bool
}{
{
name: "allow non-ignored namespace",
ignoredNamespaces: []string{"kube-system"},
eventNamespace: "default",
wantAllow: true,
},
{
name: "block ignored namespace",
ignoredNamespaces: []string{"kube-system"},
eventNamespace: "kube-system",
wantAllow: false,
},
{
name: "allow when no namespaces ignored",
ignoredNamespaces: []string{},
eventNamespace: "kube-system",
wantAllow: true,
},
{
name: "block multiple ignored namespaces",
ignoredNamespaces: []string{"kube-system", "kube-public", "test-ns"},
eventNamespace: "test-ns",
wantAllow: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := config.NewDefault()
cfg.IgnoredNamespaces = tt.ignoredNamespaces
predicate := NamespaceFilterPredicate(cfg)
cm := &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{
Name: "test-cm",
Namespace: tt.eventNamespace,
},
}
e := event.CreateEvent{Object: cm}
got := predicate.Create(e)
if got != tt.wantAllow {
t.Errorf("Create() = %v, want %v", got, tt.wantAllow)
}
})
}
}
func TestNamespaceFilterPredicate_Update(t *testing.T) {
cfg := config.NewDefault()
cfg.IgnoredNamespaces = []string{"kube-system"}
predicate := NamespaceFilterPredicate(cfg)
cm := &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{
Name: "test-cm",
Namespace: "default",
},
}
e := event.UpdateEvent{ObjectNew: cm}
if !predicate.Update(e) {
t.Error("Update() should allow non-ignored namespace")
}
cm.Namespace = "kube-system"
e = event.UpdateEvent{ObjectNew: cm}
if predicate.Update(e) {
t.Error("Update() should block ignored namespace")
}
}
func TestNamespaceFilterPredicate_Delete(t *testing.T) {
cfg := config.NewDefault()
cfg.IgnoredNamespaces = []string{"kube-system"}
predicate := NamespaceFilterPredicate(cfg)
cm := &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{
Name: "test-cm",
Namespace: "default",
},
}
e := event.DeleteEvent{Object: cm}
if !predicate.Delete(e) {
t.Error("Delete() should allow non-ignored namespace")
}
}
func TestNamespaceFilterPredicate_Generic(t *testing.T) {
cfg := config.NewDefault()
cfg.IgnoredNamespaces = []string{"kube-system"}
predicate := NamespaceFilterPredicate(cfg)
cm := &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{
Name: "test-cm",
Namespace: "default",
},
}
e := event.GenericEvent{Object: cm}
if !predicate.Generic(e) {
t.Error("Generic() should allow non-ignored namespace")
}
}
func TestLabelSelectorPredicate_Create(t *testing.T) {
tests := []struct {
name string
selector string
objectLabels map[string]string
wantAllow bool
}{
{
name: "match single label",
selector: "app=reloader",
objectLabels: map[string]string{"app": "reloader"},
wantAllow: true,
},
{
name: "no match single label",
selector: "app=reloader",
objectLabels: map[string]string{"app": "other"},
wantAllow: false,
},
{
name: "match multiple labels",
selector: "app=reloader,env=prod",
objectLabels: map[string]string{"app": "reloader", "env": "prod", "extra": "value"},
wantAllow: true,
},
{
name: "partial match fails",
selector: "app=reloader,env=prod",
objectLabels: map[string]string{"app": "reloader"},
wantAllow: false,
},
{
name: "empty labels no match",
selector: "app=reloader",
objectLabels: map[string]string{},
wantAllow: false,
},
{
name: "nil labels no match",
selector: "app=reloader",
objectLabels: nil,
wantAllow: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := config.NewDefault()
selector, err := labels.Parse(tt.selector)
if err != nil {
t.Fatalf("Failed to parse selector: %v", err)
}
cfg.ResourceSelectors = []labels.Selector{selector}
predicate := LabelSelectorPredicate(cfg)
cm := &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{
Name: "test-cm",
Namespace: "default",
Labels: tt.objectLabels,
},
}
e := event.CreateEvent{Object: cm}
got := predicate.Create(e)
if got != tt.wantAllow {
t.Errorf("Create() = %v, want %v", got, tt.wantAllow)
}
})
}
}
func TestLabelSelectorPredicate_NoSelectors(t *testing.T) {
cfg := config.NewDefault()
// No selectors configured
predicate := LabelSelectorPredicate(cfg)
cm := &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{
Name: "test-cm",
Namespace: "default",
Labels: map[string]string{"any": "label"},
},
}
e := event.CreateEvent{Object: cm}
if !predicate.Create(e) {
t.Error("Create() should allow all when no selectors configured")
}
}
func TestLabelSelectorPredicate_MultipleSelectors(t *testing.T) {
cfg := config.NewDefault()
selector1, _ := labels.Parse("app=reloader")
selector2, _ := labels.Parse("type=config")
cfg.ResourceSelectors = []labels.Selector{selector1, selector2}
predicate := LabelSelectorPredicate(cfg)
tests := []struct {
name string
labels map[string]string
wantAllow bool
}{
{
name: "matches first selector",
labels: map[string]string{"app": "reloader"},
wantAllow: true,
},
{
name: "matches second selector",
labels: map[string]string{"type": "config"},
wantAllow: true,
},
{
name: "matches both selectors",
labels: map[string]string{"app": "reloader", "type": "config"},
wantAllow: true,
},
{
name: "matches neither selector",
labels: map[string]string{"other": "value"},
wantAllow: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cm := &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{
Name: "test-cm",
Namespace: "default",
Labels: tt.labels,
},
}
e := event.CreateEvent{Object: cm}
got := predicate.Create(e)
if got != tt.wantAllow {
t.Errorf("Create() = %v, want %v", got, tt.wantAllow)
}
})
}
}
func TestLabelSelectorPredicate_Update(t *testing.T) {
cfg := config.NewDefault()
selector, _ := labels.Parse("app=reloader")
cfg.ResourceSelectors = []labels.Selector{selector}
predicate := LabelSelectorPredicate(cfg)
cmMatching := &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{
Name: "test-cm",
Namespace: "default",
Labels: map[string]string{"app": "reloader"},
},
}
cmNotMatching := &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{
Name: "test-cm",
Namespace: "default",
Labels: map[string]string{"app": "other"},
},
}
e := event.UpdateEvent{ObjectNew: cmMatching}
if !predicate.Update(e) {
t.Error("Update() should allow matching labels")
}
e = event.UpdateEvent{ObjectNew: cmNotMatching}
if predicate.Update(e) {
t.Error("Update() should block non-matching labels")
}
}
func TestLabelSelectorPredicate_Delete(t *testing.T) {
cfg := config.NewDefault()
selector, _ := labels.Parse("app=reloader")
cfg.ResourceSelectors = []labels.Selector{selector}
predicate := LabelSelectorPredicate(cfg)
cm := &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{
Name: "test-cm",
Namespace: "default",
Labels: map[string]string{"app": "reloader"},
},
}
e := event.DeleteEvent{Object: cm}
if !predicate.Delete(e) {
t.Error("Delete() should allow matching labels")
}
}
func TestLabelSelectorPredicate_Generic(t *testing.T) {
cfg := config.NewDefault()
selector, _ := labels.Parse("app=reloader")
cfg.ResourceSelectors = []labels.Selector{selector}
predicate := LabelSelectorPredicate(cfg)
cm := &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{
Name: "test-cm",
Namespace: "default",
Labels: map[string]string{"app": "reloader"},
},
}
e := event.GenericEvent{Object: cm}
if !predicate.Generic(e) {
t.Error("Generic() should allow matching labels")
}
}
func TestCombinedFiltering(t *testing.T) {
cfg := config.NewDefault()
cfg.IgnoredNamespaces = []string{"kube-system"}
selector, _ := labels.Parse("managed=true")
cfg.ResourceSelectors = []labels.Selector{selector}
nsPredicate := NamespaceFilterPredicate(cfg)
labelPredicate := LabelSelectorPredicate(cfg)
tests := []struct {
name string
namespace string
labels map[string]string
wantNSAllow bool
wantLabelAllow bool
}{
{
name: "allowed namespace and matching labels",
namespace: "default",
labels: map[string]string{"managed": "true"},
wantNSAllow: true,
wantLabelAllow: true,
},
{
name: "allowed namespace but non-matching labels",
namespace: "default",
labels: map[string]string{"managed": "false"},
wantNSAllow: true,
wantLabelAllow: false,
},
{
name: "ignored namespace with matching labels",
namespace: "kube-system",
labels: map[string]string{"managed": "true"},
wantNSAllow: false,
wantLabelAllow: true,
},
{
name: "ignored namespace and non-matching labels",
namespace: "kube-system",
labels: map[string]string{"managed": "false"},
wantNSAllow: false,
wantLabelAllow: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cm := &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{
Name: "test-cm",
Namespace: tt.namespace,
Labels: tt.labels,
},
}
e := event.CreateEvent{Object: cm}
gotNS := nsPredicate.Create(e)
if gotNS != tt.wantNSAllow {
t.Errorf("Namespace predicate Create() = %v, want %v", gotNS, tt.wantNSAllow)
}
gotLabel := labelPredicate.Create(e)
if gotLabel != tt.wantLabelAllow {
t.Errorf("Label predicate Create() = %v, want %v", gotLabel, tt.wantLabelAllow)
}
// Both must be true for the event to pass through
combinedAllow := gotNS && gotLabel
expectedCombined := tt.wantNSAllow && tt.wantLabelAllow
if combinedAllow != expectedCombined {
t.Errorf("Combined allow = %v, want %v", combinedAllow, expectedCombined)
}
})
}
}
func TestFilteringWithSecrets(t *testing.T) {
cfg := config.NewDefault()
cfg.IgnoredNamespaces = []string{"kube-system"}
nsPredicate := NamespaceFilterPredicate(cfg)
secret := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: "test-secret",
Namespace: "default",
},
}
e := event.CreateEvent{Object: secret}
if !nsPredicate.Create(e) {
t.Error("Should allow secret in non-ignored namespace")
}
secret.Namespace = "kube-system"
e = event.CreateEvent{Object: secret}
if nsPredicate.Create(e) {
t.Error("Should block secret in ignored namespace")
}
}
func TestExistsLabelSelector(t *testing.T) {
cfg := config.NewDefault()
// Selector that checks if label exists (any value)
selector, _ := labels.Parse("managed")
cfg.ResourceSelectors = []labels.Selector{selector}
predicate := LabelSelectorPredicate(cfg)
tests := []struct {
name string
labels map[string]string
wantAllow bool
}{
{
name: "label exists with value true",
labels: map[string]string{"managed": "true"},
wantAllow: true,
},
{
name: "label exists with value false",
labels: map[string]string{"managed": "false"},
wantAllow: true,
},
{
name: "label exists with empty value",
labels: map[string]string{"managed": ""},
wantAllow: true,
},
{
name: "label does not exist",
labels: map[string]string{"other": "value"},
wantAllow: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cm := &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{
Name: "test-cm",
Namespace: "default",
Labels: tt.labels,
},
}
e := event.CreateEvent{Object: cm}
got := predicate.Create(e)
if got != tt.wantAllow {
t.Errorf("Create() = %v, want %v", got, tt.wantAllow)
}
})
}
}
+47 -1
View File
@@ -2,7 +2,9 @@ package reload
import (
"context"
"encoding/json"
"fmt"
"time"
"github.com/stakater/Reloader/internal/pkg/config"
"github.com/stakater/Reloader/internal/pkg/workload"
@@ -224,7 +226,51 @@ func (s *Service) ApplyReload(
AutoReload: autoReload,
}
return s.strategy.Apply(input)
// Apply the strategy-specific changes
updated, err := s.strategy.Apply(input)
if err != nil {
return false, err
}
// Always set the attribution annotation regardless of strategy
if updated {
s.setAttributionAnnotation(wl, resourceName, resourceType, namespace, hash, container)
}
return updated, nil
}
// setAttributionAnnotation sets the last-reloaded-from annotation on the pod template.
// This is always set regardless of the reload strategy for audit purposes.
func (s *Service) setAttributionAnnotation(
wl workload.WorkloadAccessor,
resourceName string,
resourceType ResourceType,
namespace string,
hash string,
container *corev1.Container,
) {
containerName := ""
if container != nil {
containerName = container.Name
}
source := ReloadSource{
Kind: string(resourceType),
Name: resourceName,
Namespace: namespace,
Hash: hash,
Containers: []string{containerName},
ReloadedAt: time.Now().UTC(),
}
sourceJSON, err := json.Marshal(source)
if err != nil {
// Non-fatal: skip annotation if marshaling fails
return
}
wl.SetPodTemplateAnnotation(s.cfg.Annotations.LastReloadedFrom, string(sourceJSON))
}
// findTargetContainer finds the container to target for the reload.
+620
View File
@@ -0,0 +1,620 @@
package reload
import (
"context"
"testing"
"github.com/stakater/Reloader/internal/pkg/config"
"github.com/stakater/Reloader/internal/pkg/workload"
appsv1 "k8s.io/api/apps/v1"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
func TestService_ProcessConfigMap_AutoReload(t *testing.T) {
cfg := config.NewDefault()
svc := NewService(cfg)
// Create a deployment with auto annotation that uses the configmap
deploy := createTestDeployment("test-deploy", "default", map[string]string{
"reloader.stakater.com/auto": "true",
})
deploy.Spec.Template.Spec.Volumes = []corev1.Volume{
{
Name: "config-vol",
VolumeSource: corev1.VolumeSource{
ConfigMap: &corev1.ConfigMapVolumeSource{
LocalObjectReference: corev1.LocalObjectReference{
Name: "test-cm",
},
},
},
},
}
workloads := []workload.WorkloadAccessor{
workload.NewDeploymentWorkload(deploy),
}
cm := &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{
Name: "test-cm",
Namespace: "default",
},
Data: map[string]string{
"key": "value",
},
}
change := ConfigMapChange{
ConfigMap: cm,
EventType: EventTypeUpdate,
}
decisions := svc.ProcessConfigMap(change, workloads)
if len(decisions) != 1 {
t.Fatalf("Expected 1 decision, got %d", len(decisions))
}
if !decisions[0].ShouldReload {
t.Error("Expected ShouldReload to be true")
}
if !decisions[0].AutoReload {
t.Error("Expected AutoReload to be true")
}
if decisions[0].Hash == "" {
t.Error("Expected Hash to be non-empty")
}
}
func TestService_ProcessConfigMap_ExplicitAnnotation(t *testing.T) {
cfg := config.NewDefault()
svc := NewService(cfg)
// Create a deployment with explicit configmap annotation
deploy := createTestDeployment("test-deploy", "default", map[string]string{
"configmap.reloader.stakater.com/reload": "test-cm",
})
workloads := []workload.WorkloadAccessor{
workload.NewDeploymentWorkload(deploy),
}
cm := &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{
Name: "test-cm",
Namespace: "default",
},
Data: map[string]string{
"key": "value",
},
}
change := ConfigMapChange{
ConfigMap: cm,
EventType: EventTypeUpdate,
}
decisions := svc.ProcessConfigMap(change, workloads)
if len(decisions) != 1 {
t.Fatalf("Expected 1 decision, got %d", len(decisions))
}
if !decisions[0].ShouldReload {
t.Error("Expected ShouldReload to be true for explicit annotation")
}
if decisions[0].AutoReload {
t.Error("Expected AutoReload to be false for explicit annotation")
}
}
func TestService_ProcessConfigMap_IgnoredResource(t *testing.T) {
cfg := config.NewDefault()
svc := NewService(cfg)
// Create a deployment with auto annotation
deploy := createTestDeployment("test-deploy", "default", map[string]string{
"reloader.stakater.com/auto": "true",
})
deploy.Spec.Template.Spec.Volumes = []corev1.Volume{
{
Name: "config-vol",
VolumeSource: corev1.VolumeSource{
ConfigMap: &corev1.ConfigMapVolumeSource{
LocalObjectReference: corev1.LocalObjectReference{
Name: "test-cm",
},
},
},
},
}
workloads := []workload.WorkloadAccessor{
workload.NewDeploymentWorkload(deploy),
}
// ConfigMap with ignore annotation
cm := &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{
Name: "test-cm",
Namespace: "default",
Annotations: map[string]string{
"reloader.stakater.com/ignore": "true",
},
},
Data: map[string]string{
"key": "value",
},
}
change := ConfigMapChange{
ConfigMap: cm,
EventType: EventTypeUpdate,
}
decisions := svc.ProcessConfigMap(change, workloads)
// Should still get a decision, but ShouldReload should be false
for _, d := range decisions {
if d.ShouldReload {
t.Error("Expected ShouldReload to be false for ignored resource")
}
}
}
func TestService_ProcessSecret_AutoReload(t *testing.T) {
cfg := config.NewDefault()
svc := NewService(cfg)
// Create a deployment with auto annotation that uses the secret
deploy := createTestDeployment("test-deploy", "default", map[string]string{
"reloader.stakater.com/auto": "true",
})
deploy.Spec.Template.Spec.Volumes = []corev1.Volume{
{
Name: "secret-vol",
VolumeSource: corev1.VolumeSource{
Secret: &corev1.SecretVolumeSource{
SecretName: "test-secret",
},
},
},
}
workloads := []workload.WorkloadAccessor{
workload.NewDeploymentWorkload(deploy),
}
secret := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: "test-secret",
Namespace: "default",
},
Data: map[string][]byte{
"key": []byte("value"),
},
}
change := SecretChange{
Secret: secret,
EventType: EventTypeUpdate,
}
decisions := svc.ProcessSecret(change, workloads)
if len(decisions) != 1 {
t.Fatalf("Expected 1 decision, got %d", len(decisions))
}
if !decisions[0].ShouldReload {
t.Error("Expected ShouldReload to be true")
}
if !decisions[0].AutoReload {
t.Error("Expected AutoReload to be true")
}
}
func TestService_ProcessConfigMap_DeleteEvent(t *testing.T) {
cfg := config.NewDefault()
cfg.ReloadOnDelete = true
svc := NewService(cfg)
// Create a deployment with explicit configmap annotation
deploy := createTestDeployment("test-deploy", "default", map[string]string{
"configmap.reloader.stakater.com/reload": "test-cm",
})
workloads := []workload.WorkloadAccessor{
workload.NewDeploymentWorkload(deploy),
}
cm := &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{
Name: "test-cm",
Namespace: "default",
},
}
change := ConfigMapChange{
ConfigMap: cm,
EventType: EventTypeDelete,
}
decisions := svc.ProcessConfigMap(change, workloads)
if len(decisions) != 1 {
t.Fatalf("Expected 1 decision, got %d", len(decisions))
}
if !decisions[0].ShouldReload {
t.Error("Expected ShouldReload to be true for delete event")
}
// Hash should be empty for delete events
if decisions[0].Hash != "" {
t.Errorf("Expected empty hash for delete event, got %s", decisions[0].Hash)
}
}
func TestService_ProcessConfigMap_DeleteEventDisabled(t *testing.T) {
cfg := config.NewDefault()
cfg.ReloadOnDelete = false // Disabled by default
svc := NewService(cfg)
deploy := createTestDeployment("test-deploy", "default", map[string]string{
"configmap.reloader.stakater.com/reload": "test-cm",
})
workloads := []workload.WorkloadAccessor{
workload.NewDeploymentWorkload(deploy),
}
cm := &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{
Name: "test-cm",
Namespace: "default",
},
}
change := ConfigMapChange{
ConfigMap: cm,
EventType: EventTypeDelete,
}
decisions := svc.ProcessConfigMap(change, workloads)
// Should return nil when delete events are disabled
if decisions != nil {
t.Error("Expected nil decisions when delete events are disabled")
}
}
func TestService_ApplyReload_EnvVarStrategy(t *testing.T) {
cfg := config.NewDefault()
cfg.ReloadStrategy = config.ReloadStrategyEnvVars
svc := NewService(cfg)
deploy := createTestDeployment("test-deploy", "default", nil)
accessor := workload.NewDeploymentWorkload(deploy)
ctx := context.Background()
updated, err := svc.ApplyReload(ctx, accessor, "test-cm", ResourceTypeConfigMap, "default", "abc123hash", false)
if err != nil {
t.Fatalf("ApplyReload failed: %v", err)
}
if !updated {
t.Error("Expected updated to be true")
}
// Verify env var was added
containers := accessor.GetContainers()
if len(containers) == 0 {
t.Fatal("No containers found")
}
found := false
for _, env := range containers[0].Env {
if env.Name == "STAKATER_TEST_CM_CONFIGMAP" && env.Value == "abc123hash" {
found = true
break
}
}
if !found {
t.Error("Expected env var STAKATER_TEST_CM_CONFIGMAP to be set")
}
// Verify attribution annotation was set
annotations := accessor.GetPodTemplateAnnotations()
if annotations["reloader.stakater.com/last-reloaded-from"] == "" {
t.Error("Expected last-reloaded-from annotation to be set")
}
}
func TestService_ApplyReload_AnnotationStrategy(t *testing.T) {
cfg := config.NewDefault()
cfg.ReloadStrategy = config.ReloadStrategyAnnotations
svc := NewService(cfg)
deploy := createTestDeployment("test-deploy", "default", nil)
accessor := workload.NewDeploymentWorkload(deploy)
ctx := context.Background()
updated, err := svc.ApplyReload(ctx, accessor, "test-cm", ResourceTypeConfigMap, "default", "abc123hash", false)
if err != nil {
t.Fatalf("ApplyReload failed: %v", err)
}
if !updated {
t.Error("Expected updated to be true")
}
// Verify annotation was added
annotations := accessor.GetPodTemplateAnnotations()
if annotations["reloader.stakater.com/last-reloaded-from"] == "" {
t.Error("Expected last-reloaded-from annotation to be set")
}
}
func TestService_ApplyReload_EnvVarDeletion(t *testing.T) {
cfg := config.NewDefault()
cfg.ReloadStrategy = config.ReloadStrategyEnvVars
svc := NewService(cfg)
deploy := createTestDeployment("test-deploy", "default", nil)
// Pre-add an env var
deploy.Spec.Template.Spec.Containers[0].Env = []corev1.EnvVar{
{Name: "STAKATER_TEST_CM_CONFIGMAP", Value: "oldhash"},
{Name: "OTHER_VAR", Value: "keep"},
}
accessor := workload.NewDeploymentWorkload(deploy)
ctx := context.Background()
// Empty hash signals deletion
updated, err := svc.ApplyReload(ctx, accessor, "test-cm", ResourceTypeConfigMap, "default", "", false)
if err != nil {
t.Fatalf("ApplyReload failed: %v", err)
}
if !updated {
t.Error("Expected updated to be true for env var removal")
}
// Verify env var was removed
containers := accessor.GetContainers()
for _, env := range containers[0].Env {
if env.Name == "STAKATER_TEST_CM_CONFIGMAP" {
t.Error("Expected env var STAKATER_TEST_CM_CONFIGMAP to be removed")
}
}
// Verify other env var was kept
found := false
for _, env := range containers[0].Env {
if env.Name == "OTHER_VAR" {
found = true
break
}
}
if !found {
t.Error("Expected OTHER_VAR to be kept")
}
}
func TestService_ApplyReload_NoChangeIfSameHash(t *testing.T) {
cfg := config.NewDefault()
cfg.ReloadStrategy = config.ReloadStrategyEnvVars
svc := NewService(cfg)
deploy := createTestDeployment("test-deploy", "default", nil)
// Pre-add env var with same hash
deploy.Spec.Template.Spec.Containers[0].Env = []corev1.EnvVar{
{Name: "STAKATER_TEST_CM_CONFIGMAP", Value: "abc123hash"},
}
accessor := workload.NewDeploymentWorkload(deploy)
ctx := context.Background()
updated, err := svc.ApplyReload(ctx, accessor, "test-cm", ResourceTypeConfigMap, "default", "abc123hash", false)
if err != nil {
t.Fatalf("ApplyReload failed: %v", err)
}
if updated {
t.Error("Expected updated to be false when hash is unchanged")
}
}
func TestService_ProcessConfigMap_MultipleWorkloads(t *testing.T) {
cfg := config.NewDefault()
svc := NewService(cfg)
// Create multiple workloads
deploy1 := createTestDeployment("deploy1", "default", map[string]string{
"reloader.stakater.com/auto": "true",
})
deploy1.Spec.Template.Spec.Volumes = []corev1.Volume{
{
Name: "config-vol",
VolumeSource: corev1.VolumeSource{
ConfigMap: &corev1.ConfigMapVolumeSource{
LocalObjectReference: corev1.LocalObjectReference{
Name: "shared-cm",
},
},
},
},
}
deploy2 := createTestDeployment("deploy2", "default", map[string]string{
"reloader.stakater.com/auto": "true",
})
deploy2.Spec.Template.Spec.Volumes = []corev1.Volume{
{
Name: "config-vol",
VolumeSource: corev1.VolumeSource{
ConfigMap: &corev1.ConfigMapVolumeSource{
LocalObjectReference: corev1.LocalObjectReference{
Name: "shared-cm",
},
},
},
},
}
// Deploy3 doesn't use the configmap
deploy3 := createTestDeployment("deploy3", "default", map[string]string{
"reloader.stakater.com/auto": "true",
})
workloads := []workload.WorkloadAccessor{
workload.NewDeploymentWorkload(deploy1),
workload.NewDeploymentWorkload(deploy2),
workload.NewDeploymentWorkload(deploy3),
}
cm := &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{
Name: "shared-cm",
Namespace: "default",
},
Data: map[string]string{"key": "value"},
}
change := ConfigMapChange{
ConfigMap: cm,
EventType: EventTypeUpdate,
}
decisions := svc.ProcessConfigMap(change, workloads)
if len(decisions) != 3 {
t.Fatalf("Expected 3 decisions, got %d", len(decisions))
}
// Count how many should reload
reloadCount := 0
for _, d := range decisions {
if d.ShouldReload {
reloadCount++
}
}
// Only deploy1 and deploy2 should reload (they use the configmap)
if reloadCount != 2 {
t.Errorf("Expected 2 workloads to reload, got %d", reloadCount)
}
}
func TestService_ProcessConfigMap_DifferentNamespaces(t *testing.T) {
cfg := config.NewDefault()
svc := NewService(cfg)
// Create deployments in different namespaces
deploy1 := createTestDeployment("deploy1", "namespace-a", map[string]string{
"reloader.stakater.com/auto": "true",
})
deploy1.Spec.Template.Spec.Volumes = []corev1.Volume{
{
Name: "config-vol",
VolumeSource: corev1.VolumeSource{
ConfigMap: &corev1.ConfigMapVolumeSource{
LocalObjectReference: corev1.LocalObjectReference{
Name: "test-cm",
},
},
},
},
}
deploy2 := createTestDeployment("deploy2", "namespace-b", map[string]string{
"reloader.stakater.com/auto": "true",
})
deploy2.Spec.Template.Spec.Volumes = []corev1.Volume{
{
Name: "config-vol",
VolumeSource: corev1.VolumeSource{
ConfigMap: &corev1.ConfigMapVolumeSource{
LocalObjectReference: corev1.LocalObjectReference{
Name: "test-cm",
},
},
},
},
}
workloads := []workload.WorkloadAccessor{
workload.NewDeploymentWorkload(deploy1),
workload.NewDeploymentWorkload(deploy2),
}
// ConfigMap in namespace-a
cm := &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{
Name: "test-cm",
Namespace: "namespace-a",
},
Data: map[string]string{"key": "value"},
}
change := ConfigMapChange{
ConfigMap: cm,
EventType: EventTypeUpdate,
}
decisions := svc.ProcessConfigMap(change, workloads)
// Should only affect deploy1 (same namespace)
reloadCount := 0
for _, d := range decisions {
if d.ShouldReload {
reloadCount++
}
}
if reloadCount != 1 {
t.Errorf("Expected 1 workload to reload (same namespace), got %d", reloadCount)
}
}
// Helper function to create a test deployment
func createTestDeployment(name, namespace string, annotations map[string]string) *appsv1.Deployment {
replicas := int32(1)
return &appsv1.Deployment{
ObjectMeta: metav1.ObjectMeta{
Name: name,
Namespace: namespace,
Annotations: annotations,
},
Spec: appsv1.DeploymentSpec{
Replicas: &replicas,
Selector: &metav1.LabelSelector{
MatchLabels: map[string]string{"app": name},
},
Template: corev1.PodTemplateSpec{
ObjectMeta: metav1.ObjectMeta{
Labels: map[string]string{"app": name},
Annotations: map[string]string{},
},
Spec: corev1.PodSpec{
Containers: []corev1.Container{
{
Name: "main",
Image: "nginx:latest",
},
},
},
},
},
}
}
+21 -1
View File
@@ -74,7 +74,8 @@ func (s *EnvVarStrategy) Name() string {
return string(config.ReloadStrategyEnvVars)
}
// Apply adds or updates an environment variable to trigger a restart.
// Apply adds, updates, or removes an environment variable to trigger a restart.
// When hash is empty (resource deleted), the env var is removed.
func (s *EnvVarStrategy) Apply(input StrategyInput) (bool, error) {
if input.Container == nil {
return false, fmt.Errorf("container is required for env-var strategy")
@@ -82,6 +83,11 @@ func (s *EnvVarStrategy) Apply(input StrategyInput) (bool, error) {
envVarName := s.envVarName(input.ResourceName, input.ResourceType)
// Handle deletion: remove the env var when hash is empty
if input.Hash == "" {
return s.removeEnvVar(input.Container, envVarName), nil
}
// Check if env var already exists
for i := range input.Container.Env {
if input.Container.Env[i].Name == envVarName {
@@ -104,6 +110,20 @@ func (s *EnvVarStrategy) Apply(input StrategyInput) (bool, error) {
return true, nil
}
// removeEnvVar removes an environment variable from a container.
// Returns true if a variable was removed.
func (s *EnvVarStrategy) removeEnvVar(container *corev1.Container, name string) bool {
for i := range container.Env {
if container.Env[i].Name == name {
// Remove by replacing with last element and truncating
container.Env[i] = container.Env[len(container.Env)-1]
container.Env = container.Env[:len(container.Env)-1]
return true
}
}
return false
}
// envVarName generates the environment variable name for a resource.
func (s *EnvVarStrategy) envVarName(resourceName string, resourceType ResourceType) string {
var postfix string