refactor: extract matcher into separate package

This commit is contained in:
Safwan
2026-07-21 19:51:17 +05:00
parent 0098e6aef8
commit fcee92dc29
36 changed files with 157 additions and 146 deletions
@@ -1,4 +1,8 @@
package reload
// Package matcher provides the annotation-based reload decision API. Given a
// changed resource and a workload's annotations, Matcher reports whether the
// workload should be reloaded. It is workload-type agnostic and safe to import
// from outside this module.
package matcher
import (
"regexp"
@@ -1,4 +1,4 @@
package reload
package matcher
import (
"testing"
@@ -1,4 +1,4 @@
package reload
package matcher
// ResourceType represents the type of Kubernetes resource.
type ResourceType string
@@ -1,4 +1,4 @@
package reload
package matcher
import (
"testing"
-82
View File
@@ -1,82 +0,0 @@
package reload
import (
corev1 "k8s.io/api/core/v1"
csiv1 "sigs.k8s.io/secrets-store-csi-driver/apis/v1"
)
// EventType represents the type of change event.
type EventType string
const (
// EventTypeCreate indicates a resource was created.
EventTypeCreate EventType = "create"
// EventTypeUpdate indicates a resource was updated.
EventTypeUpdate EventType = "update"
// EventTypeDelete indicates a resource was deleted.
EventTypeDelete EventType = "delete"
)
// ResourceChange represents a change event for a ConfigMap or Secret.
type ResourceChange interface {
IsNil() bool
GetEventType() EventType
GetName() string
GetNamespace() string
GetAnnotations() map[string]string
GetResourceType() ResourceType
ComputeHash(hasher *Hasher) string
}
// ConfigMapChange represents a change event for a ConfigMap.
type ConfigMapChange struct {
ConfigMap *corev1.ConfigMap
EventType EventType
}
func (c ConfigMapChange) IsNil() bool { return c.ConfigMap == nil }
func (c ConfigMapChange) GetEventType() EventType { return c.EventType }
func (c ConfigMapChange) GetName() string { return c.ConfigMap.Name }
func (c ConfigMapChange) GetNamespace() string { return c.ConfigMap.Namespace }
func (c ConfigMapChange) GetAnnotations() map[string]string { return c.ConfigMap.Annotations }
func (c ConfigMapChange) GetResourceType() ResourceType { return ResourceTypeConfigMap }
func (c ConfigMapChange) ComputeHash(h *Hasher) string { return h.HashConfigMap(c.ConfigMap) }
// SecretChange represents a change event for a Secret.
type SecretChange struct {
Secret *corev1.Secret
EventType EventType
}
func (c SecretChange) IsNil() bool { return c.Secret == nil }
func (c SecretChange) GetEventType() EventType { return c.EventType }
func (c SecretChange) GetName() string { return c.Secret.Name }
func (c SecretChange) GetNamespace() string { return c.Secret.Namespace }
func (c SecretChange) GetAnnotations() map[string]string { return c.Secret.Annotations }
func (c SecretChange) GetResourceType() ResourceType { return ResourceTypeSecret }
func (c SecretChange) ComputeHash(h *Hasher) string { return h.HashSecret(c.Secret) }
// SecretProviderClassChange represents a change event derived from a
// SecretProviderClassPodStatus update. Name/Annotations refer to the resolved
// SecretProviderClass; Status carries the SPCPS status used for hashing.
type SecretProviderClassChange struct {
Name string
Namespace string
Annotations map[string]string
Status csiv1.SecretProviderClassPodStatusStatus
EventType EventType
}
func (c SecretProviderClassChange) IsNil() bool { return c.Name == "" }
func (c SecretProviderClassChange) GetEventType() EventType { return c.EventType }
func (c SecretProviderClassChange) GetName() string { return c.Name }
func (c SecretProviderClassChange) GetNamespace() string { return c.Namespace }
func (c SecretProviderClassChange) GetAnnotations() map[string]string {
return c.Annotations
}
func (c SecretProviderClassChange) GetResourceType() ResourceType {
return ResourceTypeSecretProviderClass
}
func (c SecretProviderClassChange) ComputeHash(h *Hasher) string {
return h.HashSecretProviderClass(c.Status)
}
-51
View File
@@ -1,51 +0,0 @@
package reload
import (
"testing"
csiv1 "sigs.k8s.io/secrets-store-csi-driver/apis/v1"
)
func TestSecretProviderClassChange(t *testing.T) {
status := csiv1.SecretProviderClassPodStatusStatus{
SecretProviderClassName: "my-spc",
Objects: []csiv1.SecretProviderClassObject{{ID: "a", Version: "1"}},
}
c := SecretProviderClassChange{
Name: "my-spc",
Namespace: "ns1",
Annotations: map[string]string{"k": "v"},
Status: status,
EventType: EventTypeUpdate,
}
if c.IsNil() {
t.Fatal("IsNil() = true, want false")
}
if c.GetName() != "my-spc" {
t.Fatalf("GetName() = %q", c.GetName())
}
if c.GetNamespace() != "ns1" {
t.Fatalf("GetNamespace() = %q", c.GetNamespace())
}
if c.GetResourceType() != ResourceTypeSecretProviderClass {
t.Fatalf("GetResourceType() = %q", c.GetResourceType())
}
if c.GetEventType() != EventTypeUpdate {
t.Fatalf("GetEventType() = %q", c.GetEventType())
}
if c.GetAnnotations()["k"] != "v" {
t.Fatalf("GetAnnotations() missing key")
}
h := NewHasher()
if c.ComputeHash(h) != h.HashSecretProviderClass(status) {
t.Fatalf("ComputeHash mismatch")
}
}
func TestSecretProviderClassChangeIsNil(t *testing.T) {
c := SecretProviderClassChange{Name: ""}
if !c.IsNil() {
t.Fatal("IsNil() = false, want true for empty name")
}
}
-24
View File
@@ -1,24 +0,0 @@
package reload
import (
"testing"
csiv1 "sigs.k8s.io/secrets-store-csi-driver/apis/v1"
)
// TestCSIDependencyAvailable ensures the CSI types are importable and the
// fields this feature depends on exist.
func TestCSIDependencyAvailable(t *testing.T) {
status := csiv1.SecretProviderClassPodStatusStatus{
SecretProviderClassName: "spc",
Objects: []csiv1.SecretProviderClassObject{
{ID: "secret/data/foo", Version: "1"},
},
}
if status.SecretProviderClassName != "spc" {
t.Fatalf("unexpected SecretProviderClassName")
}
if len(status.Objects) != 1 || status.Objects[0].ID != "secret/data/foo" {
t.Fatalf("unexpected Objects")
}
}
-33
View File
@@ -1,33 +0,0 @@
package reload
import (
"github.com/stakater/Reloader/internal/pkg/workload"
)
// ReloadDecision contains the result of evaluating whether to reload a workload.
//
// NOTE: not part of the public API — the Workload field is an internal/pkg/workload
// type and is therefore not usable from outside this module.
type ReloadDecision struct {
// Workload is the workload accessor.
Workload workload.Workload
// ShouldReload indicates whether the workload should be reloaded.
ShouldReload bool
// AutoReload indicates if this is an auto-reload.
AutoReload bool
// Reason provides a human-readable explanation.
Reason string
// Hash is the computed hash of the resource content.
Hash string
}
// FilterDecisions returns only decisions where ShouldReload is true.
func FilterDecisions(decisions []ReloadDecision) []ReloadDecision {
var result []ReloadDecision
for _, d := range decisions {
if d.ShouldReload {
result = append(result, d)
}
}
return result
}
-125
View File
@@ -1,125 +0,0 @@
package reload
import (
"testing"
appsv1 "k8s.io/api/apps/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"github.com/stakater/Reloader/internal/pkg/workload"
)
func TestFilterDecisions(t *testing.T) {
wl1 := workload.NewDeploymentWorkload(
&appsv1.Deployment{
ObjectMeta: metav1.ObjectMeta{Name: "deploy1", Namespace: "default"},
},
)
wl2 := workload.NewDeploymentWorkload(
&appsv1.Deployment{
ObjectMeta: metav1.ObjectMeta{Name: "deploy2", Namespace: "default"},
},
)
wl3 := workload.NewDeploymentWorkload(
&appsv1.Deployment{
ObjectMeta: metav1.ObjectMeta{Name: "deploy3", Namespace: "default"},
},
)
tests := []struct {
name string
decisions []ReloadDecision
wantCount int
wantNames []string
}{
{
name: "empty list",
decisions: []ReloadDecision{},
wantCount: 0,
wantNames: nil,
},
{
name: "all should reload",
decisions: []ReloadDecision{
{Workload: wl1, ShouldReload: true, Reason: "test"},
{Workload: wl2, ShouldReload: true, Reason: "test"},
},
wantCount: 2,
wantNames: []string{"deploy1", "deploy2"},
},
{
name: "none should reload",
decisions: []ReloadDecision{
{Workload: wl1, ShouldReload: false, Reason: "test"},
{Workload: wl2, ShouldReload: false, Reason: "test"},
},
wantCount: 0,
wantNames: nil,
},
{
name: "mixed - some should reload",
decisions: []ReloadDecision{
{Workload: wl1, ShouldReload: true, Reason: "test"},
{Workload: wl2, ShouldReload: false, Reason: "test"},
{Workload: wl3, ShouldReload: true, Reason: "test"},
},
wantCount: 2,
wantNames: []string{"deploy1", "deploy3"},
},
}
for _, tt := range tests {
t.Run(
tt.name, func(t *testing.T) {
result := FilterDecisions(tt.decisions)
if len(result) != tt.wantCount {
t.Errorf("FilterDecisions() returned %d decisions, want %d", len(result), tt.wantCount)
}
if tt.wantNames != nil {
for i, d := range result {
if d.Workload.GetName() != tt.wantNames[i] {
t.Errorf(
"FilterDecisions()[%d].Workload.GetName() = %s, want %s",
i, d.Workload.GetName(), tt.wantNames[i],
)
}
}
}
},
)
}
}
func TestReloadDecision_Fields(t *testing.T) {
wl := workload.NewDeploymentWorkload(
&appsv1.Deployment{
ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"},
},
)
decision := ReloadDecision{
Workload: wl,
ShouldReload: true,
AutoReload: true,
Reason: "test reason",
Hash: "abc123",
}
if decision.Workload.GetName() != "test" {
t.Errorf("ReloadDecision.Workload.GetName() = %v, want test", decision.Workload.GetName())
}
if !decision.ShouldReload {
t.Error("ReloadDecision.ShouldReload should be true")
}
if !decision.AutoReload {
t.Error("ReloadDecision.AutoReload should be true")
}
if decision.Reason != "test reason" {
t.Errorf("ReloadDecision.Reason = %v, want 'test reason'", decision.Reason)
}
if decision.Hash != "abc123" {
t.Errorf("ReloadDecision.Hash = %v, want 'abc123'", decision.Hash)
}
}
-88
View File
@@ -1,88 +0,0 @@
// Package reload provides core reload logic for ConfigMaps and Secrets.
package reload
import (
"crypto/sha1"
"encoding/base64"
"fmt"
"io"
"sort"
"strings"
corev1 "k8s.io/api/core/v1"
csiv1 "sigs.k8s.io/secrets-store-csi-driver/apis/v1"
)
// Hasher computes content hashes for ConfigMaps and Secrets.
type Hasher struct{}
// NewHasher creates a new Hasher instance.
func NewHasher() *Hasher {
return &Hasher{}
}
// HashConfigMap computes a SHA1 hash of the ConfigMap's data and binaryData.
func (h *Hasher) HashConfigMap(cm *corev1.ConfigMap) string {
if cm == nil {
return h.computeSHA("")
}
return h.hashConfigMapData(cm.Data, cm.BinaryData)
}
// HashSecret computes a SHA1 hash of the Secret's data.
func (h *Hasher) HashSecret(secret *corev1.Secret) string {
if secret == nil {
return h.computeSHA("")
}
return h.hashSecretData(secret.Data)
}
func (h *Hasher) hashConfigMapData(data map[string]string, binaryData map[string][]byte) string {
values := make([]string, 0, len(data)+len(binaryData))
for k, v := range data {
values = append(values, k+"="+v)
}
for k, v := range binaryData {
values = append(values, k+"="+base64.StdEncoding.EncodeToString(v))
}
sort.Strings(values)
return h.computeSHA(strings.Join(values, ";"))
}
func (h *Hasher) hashSecretData(data map[string][]byte) string {
values := make([]string, 0, len(data))
for k, v := range data {
values = append(values, k+"="+string(v))
}
sort.Strings(values)
return h.computeSHA(strings.Join(values, ";"))
}
func (h *Hasher) computeSHA(data string) string {
hasher := sha1.New()
_, _ = io.WriteString(hasher, data)
return fmt.Sprintf("%x", hasher.Sum(nil))
}
// HashSecretProviderClass computes a SHA1 hash of a SecretProviderClassPodStatus
// status: the sorted set of object ID=Version entries plus the SPC name.
// This mirrors master's util.GetSHAfromSecretProviderClassPodStatus exactly.
func (h *Hasher) HashSecretProviderClass(status csiv1.SecretProviderClassPodStatusStatus) string {
values := make([]string, 0, len(status.Objects)+1)
for _, obj := range status.Objects {
values = append(values, obj.ID+"="+obj.Version)
}
values = append(values, "SecretProviderClassName="+status.SecretProviderClassName)
sort.Strings(values)
return h.computeSHA(strings.Join(values, ";"))
}
// EmptyHash returns an empty string to signal resource deletion.
func (h *Hasher) EmptyHash() string {
return ""
}
-279
View File
@@ -1,279 +0,0 @@
package reload
import (
"testing"
corev1 "k8s.io/api/core/v1"
csiv1 "sigs.k8s.io/secrets-store-csi-driver/apis/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.HashConfigMap(&corev1.ConfigMap{}),
},
{
name: "configmap with data",
cm: &corev1.ConfigMap{
Data: map[string]string{
"key1": "value1",
"key2": "value2",
},
},
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",
},
}
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.HashSecret(&corev1.Secret{}),
},
{
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"),
},
}
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.Errorf("EmptyHash should be empty string, got %s", emptyHash)
}
cm := &corev1.ConfigMap{}
cmHash := hasher.HashConfigMap(cm)
if cmHash == "" {
t.Error("Empty ConfigMap should have a non-empty hash")
}
secret := &corev1.Secret{}
secretHash := hasher.HashSecret(secret)
if secretHash == "" {
t.Error("Empty Secret should have a non-empty hash")
}
}
func TestHasher_NilInput(t *testing.T) {
hasher := NewHasher()
cmHash := hasher.HashConfigMap(nil)
if cmHash == "" {
t.Error("nil ConfigMap should return a valid hash")
}
secretHash := hasher.HashSecret(nil)
if secretHash == "" {
t.Error("nil Secret should return a valid hash")
}
}
func TestHashSecretProviderClass(t *testing.T) {
h := NewHasher()
status := csiv1.SecretProviderClassPodStatusStatus{
SecretProviderClassName: "my-spc",
Objects: []csiv1.SecretProviderClassObject{
{ID: "secret/data/b", Version: "2"},
{ID: "secret/data/a", Version: "1"},
},
}
// Expected = SHA1 hex of the sorted, ';'-joined string, matching master.
expectedInput := "SecretProviderClassName=my-spc;secret/data/a=1;secret/data/b=2"
expected := h.computeSHA(expectedInput)
got := h.HashSecretProviderClass(status)
if got != expected {
t.Fatalf("HashSecretProviderClass = %q, want %q", got, expected)
}
// Order independence: shuffling objects must not change the hash.
statusReordered := csiv1.SecretProviderClassPodStatusStatus{
SecretProviderClassName: "my-spc",
Objects: []csiv1.SecretProviderClassObject{
{ID: "secret/data/a", Version: "1"},
{ID: "secret/data/b", Version: "2"},
},
}
if h.HashSecretProviderClass(statusReordered) != got {
t.Fatalf("hash not order-independent")
}
}
func TestHashSecretProviderClassEmpty(t *testing.T) {
h := NewHasher()
status := csiv1.SecretProviderClassPodStatusStatus{SecretProviderClassName: "empty"}
got := h.HashSecretProviderClass(status)
want := h.computeSHA("SecretProviderClassName=empty")
if got != want {
t.Fatalf("HashSecretProviderClass(empty) = %q, want %q", got, want)
}
}
-131
View File
@@ -1,131 +0,0 @@
package reload
import (
"fmt"
"time"
appsv1 "k8s.io/api/apps/v1"
"github.com/stakater/Reloader/internal/pkg/workload"
"github.com/stakater/Reloader/pkg/config"
)
// PauseHandler handles pause deployment logic.
//
// NOTE: not part of the public API — its methods reference internal/pkg/workload
// and are therefore not usable from outside this module.
type PauseHandler struct {
cfg *config.Config
}
// NewPauseHandler creates a new PauseHandler.
func NewPauseHandler(cfg *config.Config) *PauseHandler {
return &PauseHandler{cfg: cfg}
}
// ShouldPause checks if a deployment should be paused after reload.
func (h *PauseHandler) ShouldPause(wl workload.Workload) bool {
if wl.Kind() != workload.KindDeployment {
return false
}
annotations := wl.GetAnnotations()
if annotations == nil {
return false
}
pausePeriod := annotations[h.cfg.Annotations.PausePeriod]
return pausePeriod != ""
}
// GetPausePeriod returns the configured pause period for a workload.
func (h *PauseHandler) GetPausePeriod(wl workload.Workload) (time.Duration, error) {
annotations := wl.GetAnnotations()
if annotations == nil {
return 0, fmt.Errorf("no annotations on workload")
}
pausePeriodStr := annotations[h.cfg.Annotations.PausePeriod]
if pausePeriodStr == "" {
return 0, fmt.Errorf("no pause period annotation")
}
return time.ParseDuration(pausePeriodStr)
}
// ApplyPause pauses a deployment and sets the paused-at annotation.
func (h *PauseHandler) ApplyPause(wl workload.Workload) error {
deployWl, ok := wl.(*workload.DeploymentWorkload)
if !ok {
return fmt.Errorf("workload is not a deployment")
}
deploy := deployWl.GetDeployment()
deploy.Spec.Paused = true
if deploy.Annotations == nil {
deploy.Annotations = make(map[string]string)
}
deploy.Annotations[h.cfg.Annotations.PausedAt] = time.Now().UTC().Format(time.RFC3339)
return nil
}
// CheckPauseExpired checks if the pause period has expired for a deployment.
func (h *PauseHandler) CheckPauseExpired(deploy *appsv1.Deployment) (expired bool, remainingTime time.Duration, err error) {
annotations := deploy.GetAnnotations()
if annotations == nil {
return false, 0, fmt.Errorf("no annotations on deployment")
}
pausePeriodStr := annotations[h.cfg.Annotations.PausePeriod]
if pausePeriodStr == "" {
return false, 0, fmt.Errorf("no pause period annotation")
}
pausedAtStr := annotations[h.cfg.Annotations.PausedAt]
if pausedAtStr == "" {
return false, 0, fmt.Errorf("no paused-at annotation")
}
pausePeriod, err := time.ParseDuration(pausePeriodStr)
if err != nil {
return false, 0, fmt.Errorf("invalid pause period %q: %w", pausePeriodStr, err)
}
pausedAt, err := time.Parse(time.RFC3339, pausedAtStr)
if err != nil {
return false, 0, fmt.Errorf("invalid paused-at time %q: %w", pausedAtStr, err)
}
elapsed := time.Since(pausedAt)
if elapsed >= pausePeriod {
return true, 0, nil
}
return false, pausePeriod - elapsed, nil
}
// ClearPause removes the pause from a deployment.
func (h *PauseHandler) ClearPause(deploy *appsv1.Deployment) {
deploy.Spec.Paused = false
delete(deploy.Annotations, h.cfg.Annotations.PausedAt)
}
// IsPausedByReloader checks if a deployment was paused by Reloader.
func (h *PauseHandler) IsPausedByReloader(deploy *appsv1.Deployment) bool {
if !deploy.Spec.Paused {
return false
}
annotations := deploy.GetAnnotations()
if annotations == nil {
return false
}
_, hasPausedAt := annotations[h.cfg.Annotations.PausedAt]
_, hasPausePeriod := annotations[h.cfg.Annotations.PausePeriod]
return hasPausedAt && hasPausePeriod
}
-328
View File
@@ -1,328 +0,0 @@
package reload
import (
"testing"
"time"
appsv1 "k8s.io/api/apps/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"github.com/stakater/Reloader/internal/pkg/workload"
"github.com/stakater/Reloader/pkg/config"
)
func TestPauseHandler_ShouldPause(t *testing.T) {
cfg := config.NewDefault()
handler := NewPauseHandler(cfg)
tests := []struct {
name string
workload workload.Workload
want bool
}{
{
name: "deployment with pause period",
workload: workload.NewDeploymentWorkload(&appsv1.Deployment{
ObjectMeta: metav1.ObjectMeta{
Annotations: map[string]string{
cfg.Annotations.PausePeriod: "5m",
},
},
}),
want: true,
},
{
name: "deployment without pause period",
workload: workload.NewDeploymentWorkload(&appsv1.Deployment{
ObjectMeta: metav1.ObjectMeta{},
}),
want: false,
},
{
name: "daemonset with pause period (ignored)",
workload: workload.NewDaemonSetWorkload(&appsv1.DaemonSet{
ObjectMeta: metav1.ObjectMeta{
Annotations: map[string]string{
cfg.Annotations.PausePeriod: "5m",
},
},
}),
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := handler.ShouldPause(tt.workload)
if got != tt.want {
t.Errorf("ShouldPause() = %v, want %v", got, tt.want)
}
})
}
}
func TestPauseHandler_GetPausePeriod(t *testing.T) {
cfg := config.NewDefault()
handler := NewPauseHandler(cfg)
tests := []struct {
name string
workload workload.Workload
wantPeriod time.Duration
wantErr bool
}{
{
name: "valid pause period",
workload: workload.NewDeploymentWorkload(&appsv1.Deployment{
ObjectMeta: metav1.ObjectMeta{
Annotations: map[string]string{
cfg.Annotations.PausePeriod: "5m",
},
},
}),
wantPeriod: 5 * time.Minute,
wantErr: false,
},
{
name: "invalid pause period",
workload: workload.NewDeploymentWorkload(&appsv1.Deployment{
ObjectMeta: metav1.ObjectMeta{
Annotations: map[string]string{
cfg.Annotations.PausePeriod: "invalid",
},
},
}),
wantErr: true,
},
{
name: "no pause period annotation",
workload: workload.NewDeploymentWorkload(&appsv1.Deployment{
ObjectMeta: metav1.ObjectMeta{},
}),
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := handler.GetPausePeriod(tt.workload)
if (err != nil) != tt.wantErr {
t.Errorf("GetPausePeriod() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !tt.wantErr && got != tt.wantPeriod {
t.Errorf("GetPausePeriod() = %v, want %v", got, tt.wantPeriod)
}
})
}
}
func TestPauseHandler_ApplyPause(t *testing.T) {
cfg := config.NewDefault()
handler := NewPauseHandler(cfg)
deploy := &appsv1.Deployment{
ObjectMeta: metav1.ObjectMeta{
Name: "test-deploy",
},
Spec: appsv1.DeploymentSpec{
Paused: false,
},
}
wl := workload.NewDeploymentWorkload(deploy)
err := handler.ApplyPause(wl)
if err != nil {
t.Fatalf("ApplyPause() error = %v", err)
}
if !deploy.Spec.Paused {
t.Error("Expected deployment to be paused")
}
pausedAt := deploy.Annotations[cfg.Annotations.PausedAt]
if pausedAt == "" {
t.Error("Expected paused-at annotation to be set")
}
// Verify the timestamp is valid
_, err = time.Parse(time.RFC3339, pausedAt)
if err != nil {
t.Errorf("Invalid paused-at timestamp: %v", err)
}
}
func TestPauseHandler_CheckPauseExpired(t *testing.T) {
cfg := config.NewDefault()
handler := NewPauseHandler(cfg)
tests := []struct {
name string
deploy *appsv1.Deployment
wantExpired bool
wantErr bool
}{
{
name: "pause expired",
deploy: &appsv1.Deployment{
ObjectMeta: metav1.ObjectMeta{
Annotations: map[string]string{
cfg.Annotations.PausePeriod: "1ms",
cfg.Annotations.PausedAt: time.Now().Add(-time.Second).UTC().Format(time.RFC3339),
},
},
Spec: appsv1.DeploymentSpec{Paused: true},
},
wantExpired: true,
wantErr: false,
},
{
name: "pause not expired",
deploy: &appsv1.Deployment{
ObjectMeta: metav1.ObjectMeta{
Annotations: map[string]string{
cfg.Annotations.PausePeriod: "1h",
cfg.Annotations.PausedAt: time.Now().UTC().Format(time.RFC3339),
},
},
Spec: appsv1.DeploymentSpec{Paused: true},
},
wantExpired: false,
wantErr: false,
},
{
name: "no paused-at annotation",
deploy: &appsv1.Deployment{
ObjectMeta: metav1.ObjectMeta{
Annotations: map[string]string{
cfg.Annotations.PausePeriod: "5m",
},
},
},
wantErr: true,
},
{
name: "invalid pause period",
deploy: &appsv1.Deployment{
ObjectMeta: metav1.ObjectMeta{
Annotations: map[string]string{
cfg.Annotations.PausePeriod: "invalid",
cfg.Annotations.PausedAt: time.Now().UTC().Format(time.RFC3339),
},
},
},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
expired, _, err := handler.CheckPauseExpired(tt.deploy)
if (err != nil) != tt.wantErr {
t.Errorf("CheckPauseExpired() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !tt.wantErr && expired != tt.wantExpired {
t.Errorf("CheckPauseExpired() expired = %v, want %v", expired, tt.wantExpired)
}
})
}
}
func TestPauseHandler_ClearPause(t *testing.T) {
cfg := config.NewDefault()
handler := NewPauseHandler(cfg)
deploy := &appsv1.Deployment{
ObjectMeta: metav1.ObjectMeta{
Annotations: map[string]string{
cfg.Annotations.PausePeriod: "5m",
cfg.Annotations.PausedAt: time.Now().UTC().Format(time.RFC3339),
},
},
Spec: appsv1.DeploymentSpec{
Paused: true,
},
}
handler.ClearPause(deploy)
if deploy.Spec.Paused {
t.Error("Expected deployment to be unpaused")
}
if _, exists := deploy.Annotations[cfg.Annotations.PausedAt]; exists {
t.Error("Expected paused-at annotation to be removed")
}
// Pause period should be preserved (user's config)
if deploy.Annotations[cfg.Annotations.PausePeriod] != "5m" {
t.Error("Expected pause-period annotation to be preserved")
}
}
func TestPauseHandler_IsPausedByReloader(t *testing.T) {
cfg := config.NewDefault()
handler := NewPauseHandler(cfg)
tests := []struct {
name string
deploy *appsv1.Deployment
want bool
}{
{
name: "paused by reloader",
deploy: &appsv1.Deployment{
ObjectMeta: metav1.ObjectMeta{
Annotations: map[string]string{
cfg.Annotations.PausePeriod: "5m",
cfg.Annotations.PausedAt: time.Now().UTC().Format(time.RFC3339),
},
},
Spec: appsv1.DeploymentSpec{Paused: true},
},
want: true,
},
{
name: "paused but not by reloader (no paused-at)",
deploy: &appsv1.Deployment{
ObjectMeta: metav1.ObjectMeta{
Annotations: map[string]string{
cfg.Annotations.PausePeriod: "5m",
},
},
Spec: appsv1.DeploymentSpec{Paused: true},
},
want: false,
},
{
name: "not paused",
deploy: &appsv1.Deployment{
ObjectMeta: metav1.ObjectMeta{
Annotations: map[string]string{
cfg.Annotations.PausePeriod: "5m",
cfg.Annotations.PausedAt: time.Now().UTC().Format(time.RFC3339),
},
},
Spec: appsv1.DeploymentSpec{Paused: false},
},
want: false,
},
{
name: "no annotations",
deploy: &appsv1.Deployment{
Spec: appsv1.DeploymentSpec{Paused: true},
},
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := handler.IsPausedByReloader(tt.deploy)
if got != tt.want {
t.Errorf("IsPausedByReloader() = %v, want %v", got, tt.want)
}
})
}
}
-179
View File
@@ -1,179 +0,0 @@
package reload
import (
corev1 "k8s.io/api/core/v1"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/event"
"sigs.k8s.io/controller-runtime/pkg/predicate"
csiv1 "sigs.k8s.io/secrets-store-csi-driver/apis/v1"
"github.com/stakater/Reloader/pkg/config"
)
// resourcePredicates returns predicates for filtering resource events.
// The hashFn computes a hash from old and new objects to detect content changes.
func resourcePredicates(cfg *config.Config, hashFn func(old, new client.Object) (string, string, bool)) predicate.Predicate {
return predicate.Funcs{
CreateFunc: func(e event.CreateEvent) bool {
return cfg.ReloadOnCreate || cfg.SyncAfterRestart
},
UpdateFunc: func(e event.UpdateEvent) bool {
oldHash, newHash, ok := hashFn(e.ObjectOld, e.ObjectNew)
if !ok {
return false
}
return oldHash != newHash
},
DeleteFunc: func(e event.DeleteEvent) bool {
return cfg.ReloadOnDelete
},
GenericFunc: func(e event.GenericEvent) bool {
return false
},
}
}
// ConfigMapPredicates returns predicates for filtering ConfigMap events.
func ConfigMapPredicates(cfg *config.Config, hasher *Hasher) predicate.Predicate {
return resourcePredicates(
cfg, func(old, new client.Object) (string, string, bool) {
oldCM, okOld := old.(*corev1.ConfigMap)
newCM, okNew := new.(*corev1.ConfigMap)
if !okOld || !okNew {
return "", "", false
}
return hasher.HashConfigMap(oldCM), hasher.HashConfigMap(newCM), true
},
)
}
// SecretPredicates returns predicates for filtering Secret events.
func SecretPredicates(cfg *config.Config, hasher *Hasher) predicate.Predicate {
return resourcePredicates(
cfg, func(old, new client.Object) (string, string, bool) {
oldSecret, okOld := old.(*corev1.Secret)
newSecret, okNew := new.(*corev1.Secret)
if !okOld || !okNew {
return "", "", false
}
return hasher.HashSecret(oldSecret), hasher.HashSecret(newSecret), true
},
)
}
// NamespaceChecker defines the interface for checking if a namespace is allowed.
type NamespaceChecker interface {
Contains(name string) bool
}
// NamespaceFilterPredicate returns a predicate that filters resources by namespace.
func NamespaceFilterPredicate(cfg *config.Config) predicate.Predicate {
return NamespaceFilterPredicateWithCache(cfg, nil)
}
// NamespaceFilterPredicateWithCache returns a predicate that filters resources by namespace,
// using the provided NamespaceChecker for namespace selector filtering.
func NamespaceFilterPredicateWithCache(cfg *config.Config, nsCache NamespaceChecker) predicate.Predicate {
return predicate.NewPredicateFuncs(
func(obj client.Object) bool {
namespace := obj.GetNamespace()
if cfg.IsNamespaceIgnored(namespace) {
return false
}
if nsCache != nil && !nsCache.Contains(namespace) {
return false
}
return true
},
)
}
// LabelSelectorPredicate returns a predicate that filters resources by labels.
func LabelSelectorPredicate(cfg *config.Config) predicate.Predicate {
if len(cfg.ResourceSelectors) == 0 {
return predicate.NewPredicateFuncs(
func(obj client.Object) bool {
return true
},
)
}
return predicate.NewPredicateFuncs(
func(obj client.Object) bool {
labels := obj.GetLabels()
if labels == nil {
labels = make(map[string]string)
}
for _, selector := range cfg.ResourceSelectors {
if selector.Matches(LabelsSet(labels)) {
return true
}
}
return false
},
)
}
// LabelsSet implements the k8s.io/apimachinery/pkg/labels.Labels interface
// for a map[string]string. This allows using label maps with label selectors.
type LabelsSet map[string]string
// Has returns whether the provided label key exists in the set.
func (ls LabelsSet) Has(key string) bool {
_, ok := ls[key]
return ok
}
// Get returns the value for the provided label key.
func (ls LabelsSet) Get(key string) string {
return ls[key]
}
// Lookup returns the value for the provided label key and whether it exists.
func (ls LabelsSet) Lookup(key string) (string, bool) {
value, ok := ls[key]
return value, ok
}
// IgnoreAnnotationPredicate returns a predicate that filters out resources with the ignore annotation.
func IgnoreAnnotationPredicate(cfg *config.Config) predicate.Predicate {
return predicate.NewPredicateFuncs(
func(obj client.Object) bool {
annotations := obj.GetAnnotations()
if annotations == nil {
return true
}
return annotations[cfg.Annotations.Ignore] != "true"
},
)
}
// CombinedPredicates combines multiple predicates with AND logic.
func CombinedPredicates(predicates ...predicate.Predicate) predicate.Predicate {
return predicate.And(predicates...)
}
// SecretProviderClassPodStatusPredicates filters SecretProviderClassPodStatus events.
// Create and Delete are ignored (matching master); Update passes only when the
// hashed status (object IDs/versions + SPC name) changes.
func SecretProviderClassPodStatusPredicates(cfg *config.Config, hasher *Hasher) predicate.Predicate {
return predicate.Funcs{
CreateFunc: func(e event.CreateEvent) bool { return false },
DeleteFunc: func(e event.DeleteEvent) bool { return false },
GenericFunc: func(e event.GenericEvent) bool { return false },
UpdateFunc: func(e event.UpdateEvent) bool {
oldObj, okOld := e.ObjectOld.(*csiv1.SecretProviderClassPodStatus)
newObj, okNew := e.ObjectNew.(*csiv1.SecretProviderClassPodStatus)
if !okOld || !okNew {
return false
}
return hasher.HashSecretProviderClass(oldObj.Status) != hasher.HashSecretProviderClass(newObj.Status)
},
}
}
-986
View File
@@ -1,986 +0,0 @@
package reload
import (
"testing"
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"
csiv1 "sigs.k8s.io/secrets-store-csi-driver/apis/v1"
"github.com/stakater/Reloader/pkg/config"
)
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()
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)
}
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, _ := 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)
}
},
)
}
}
// mockNamespaceChecker implements NamespaceChecker for testing.
type mockNamespaceChecker struct {
allowed map[string]bool
}
func (m *mockNamespaceChecker) Contains(name string) bool {
return m.allowed[name]
}
func TestNamespaceFilterPredicateWithCache(t *testing.T) {
tests := []struct {
name string
ignoredNamespaces []string
cacheAllowed map[string]bool
eventNamespace string
wantAllow bool
}{
{
name: "allowed by cache and not ignored",
ignoredNamespaces: []string{"kube-system"},
cacheAllowed: map[string]bool{"production": true},
eventNamespace: "production",
wantAllow: true,
},
{
name: "blocked by cache",
ignoredNamespaces: []string{},
cacheAllowed: map[string]bool{"production": true},
eventNamespace: "staging",
wantAllow: false,
},
{
name: "blocked by ignore list even if in cache",
ignoredNamespaces: []string{"kube-system"},
cacheAllowed: map[string]bool{"kube-system": true},
eventNamespace: "kube-system",
wantAllow: false,
},
{
name: "ignore list checked before cache",
ignoredNamespaces: []string{"blocked-ns"},
cacheAllowed: map[string]bool{"blocked-ns": true},
eventNamespace: "blocked-ns",
wantAllow: false,
},
}
for _, tt := range tests {
t.Run(
tt.name, func(t *testing.T) {
cfg := config.NewDefault()
cfg.IgnoredNamespaces = tt.ignoredNamespaces
cache := &mockNamespaceChecker{allowed: tt.cacheAllowed}
predicate := NamespaceFilterPredicateWithCache(cfg, cache)
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 TestNamespaceFilterPredicateWithCache_NilCache(t *testing.T) {
cfg := config.NewDefault()
cfg.IgnoredNamespaces = []string{"kube-system"}
predicate := NamespaceFilterPredicateWithCache(cfg, nil)
tests := []struct {
namespace string
wantAllow bool
}{
{"default", true},
{"production", true},
{"kube-system", false}, // Should still respect ignore list
}
for _, tt := range tests {
t.Run(
tt.namespace, func(t *testing.T) {
cm := &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{
Name: "test-cm",
Namespace: tt.namespace,
},
}
e := event.CreateEvent{Object: cm}
got := predicate.Create(e)
if got != tt.wantAllow {
t.Errorf("Create() = %v, want %v for namespace %s", got, tt.wantAllow, tt.namespace)
}
},
)
}
}
func TestIgnoreAnnotationPredicate_Create(t *testing.T) {
cfg := config.NewDefault()
predicate := IgnoreAnnotationPredicate(cfg)
tests := []struct {
name string
annotations map[string]string
wantAllow bool
}{
{
name: "no annotations",
annotations: nil,
wantAllow: true,
},
{
name: "empty annotations",
annotations: map[string]string{},
wantAllow: true,
},
{
name: "other annotations only",
annotations: map[string]string{"other": "value"},
wantAllow: true,
},
{
name: "ignore annotation true",
annotations: map[string]string{cfg.Annotations.Ignore: "true"},
wantAllow: false,
},
{
name: "ignore annotation false",
annotations: map[string]string{cfg.Annotations.Ignore: "false"},
wantAllow: true,
},
{
name: "ignore annotation with other value",
annotations: map[string]string{cfg.Annotations.Ignore: "yes"},
wantAllow: true,
},
}
for _, tt := range tests {
t.Run(
tt.name, func(t *testing.T) {
cm := &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{
Name: "test-cm",
Namespace: "default",
Annotations: tt.annotations,
},
}
e := event.CreateEvent{Object: cm}
got := predicate.Create(e)
if got != tt.wantAllow {
t.Errorf("Create() = %v, want %v", got, tt.wantAllow)
}
},
)
}
}
func TestIgnoreAnnotationPredicate_AllEventTypes(t *testing.T) {
cfg := config.NewDefault()
predicate := IgnoreAnnotationPredicate(cfg)
ignoredCM := &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{
Name: "ignored-cm",
Namespace: "default",
Annotations: map[string]string{cfg.Annotations.Ignore: "true"},
},
}
allowedCM := &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{
Name: "allowed-cm",
Namespace: "default",
},
}
if predicate.Update(event.UpdateEvent{ObjectNew: ignoredCM}) {
t.Error("Update() should block ignored resource")
}
if !predicate.Update(event.UpdateEvent{ObjectNew: allowedCM}) {
t.Error("Update() should allow non-ignored resource")
}
if predicate.Delete(event.DeleteEvent{Object: ignoredCM}) {
t.Error("Delete() should block ignored resource")
}
if !predicate.Delete(event.DeleteEvent{Object: allowedCM}) {
t.Error("Delete() should allow non-ignored resource")
}
if predicate.Generic(event.GenericEvent{Object: ignoredCM}) {
t.Error("Generic() should block ignored resource")
}
if !predicate.Generic(event.GenericEvent{Object: allowedCM}) {
t.Error("Generic() should allow non-ignored resource")
}
}
func TestCombinedPredicates(t *testing.T) {
cfg := config.NewDefault()
cfg.IgnoredNamespaces = []string{"kube-system"}
nsPredicate := NamespaceFilterPredicate(cfg)
ignorePredicate := IgnoreAnnotationPredicate(cfg)
combined := CombinedPredicates(nsPredicate, ignorePredicate)
tests := []struct {
name string
namespace string
annotations map[string]string
wantAllow bool
}{
{
name: "both predicates pass",
namespace: "default",
annotations: nil,
wantAllow: true,
},
{
name: "namespace predicate fails",
namespace: "kube-system",
annotations: nil,
wantAllow: false,
},
{
name: "ignore predicate fails",
namespace: "default",
annotations: map[string]string{cfg.Annotations.Ignore: "true"},
wantAllow: false,
},
{
name: "both predicates fail",
namespace: "kube-system",
annotations: map[string]string{cfg.Annotations.Ignore: "true"},
wantAllow: 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,
Annotations: tt.annotations,
},
}
e := event.CreateEvent{Object: cm}
got := combined.Create(e)
if got != tt.wantAllow {
t.Errorf("Create() = %v, want %v", got, tt.wantAllow)
}
},
)
}
}
func TestConfigMapPredicates_Update(t *testing.T) {
cfg := config.NewDefault()
hasher := NewHasher()
predicate := ConfigMapPredicates(cfg, hasher)
oldCM := &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"},
Data: map[string]string{"key": "value1"},
}
newCMSameContent := &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"},
Data: map[string]string{"key": "value1"},
}
newCMDifferentContent := &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"},
Data: map[string]string{"key": "value2"},
}
e := event.UpdateEvent{ObjectOld: oldCM, ObjectNew: newCMSameContent}
if predicate.Update(e) {
t.Error("Update() should return false when content is the same")
}
e = event.UpdateEvent{ObjectOld: oldCM, ObjectNew: newCMDifferentContent}
if !predicate.Update(e) {
t.Error("Update() should return true when content changed")
}
}
func TestConfigMapPredicates_InvalidTypes(t *testing.T) {
cfg := config.NewDefault()
hasher := NewHasher()
predicate := ConfigMapPredicates(cfg, hasher)
secret := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"},
}
cm := &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"},
}
e := event.UpdateEvent{ObjectOld: secret, ObjectNew: cm}
if predicate.Update(e) {
t.Error("Update() should return false for mismatched types")
}
e = event.UpdateEvent{ObjectOld: secret, ObjectNew: secret}
if predicate.Update(e) {
t.Error("Update() should return false for non-ConfigMap types")
}
}
func TestConfigMapPredicates_CreateDeleteGeneric(t *testing.T) {
cfg := config.NewDefault()
cfg.ReloadOnCreate = true
cfg.ReloadOnDelete = true
hasher := NewHasher()
predicate := ConfigMapPredicates(cfg, hasher)
cm := &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"},
}
if !predicate.Create(event.CreateEvent{Object: cm}) {
t.Error("Create() should return true when ReloadOnCreate is true")
}
if !predicate.Delete(event.DeleteEvent{Object: cm}) {
t.Error("Delete() should return true when ReloadOnDelete is true")
}
if predicate.Generic(event.GenericEvent{Object: cm}) {
t.Error("Generic() should always return false")
}
}
func TestSecretPredicates_Update(t *testing.T) {
cfg := config.NewDefault()
hasher := NewHasher()
predicate := SecretPredicates(cfg, hasher)
oldSecret := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"},
Data: map[string][]byte{"key": []byte("value1")},
}
newSecretSameContent := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"},
Data: map[string][]byte{"key": []byte("value1")},
}
newSecretDifferentContent := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"},
Data: map[string][]byte{"key": []byte("value2")},
}
e := event.UpdateEvent{ObjectOld: oldSecret, ObjectNew: newSecretSameContent}
if predicate.Update(e) {
t.Error("Update() should return false when content is the same")
}
e = event.UpdateEvent{ObjectOld: oldSecret, ObjectNew: newSecretDifferentContent}
if !predicate.Update(e) {
t.Error("Update() should return true when content changed")
}
}
func TestSecretPredicates_InvalidTypes(t *testing.T) {
cfg := config.NewDefault()
hasher := NewHasher()
predicate := SecretPredicates(cfg, hasher)
cm := &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"},
}
secret := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"},
}
e := event.UpdateEvent{ObjectOld: cm, ObjectNew: secret}
if predicate.Update(e) {
t.Error("Update() should return false for mismatched types")
}
e = event.UpdateEvent{ObjectOld: cm, ObjectNew: cm}
if predicate.Update(e) {
t.Error("Update() should return false for non-Secret types")
}
}
func TestLabelsSet(t *testing.T) {
ls := LabelsSet{"app": "test", "env": "prod"}
if !ls.Has("app") {
t.Error("Has(app) should return true")
}
if ls.Has("nonexistent") {
t.Error("Has(nonexistent) should return false")
}
if ls.Get("app") != "test" {
t.Errorf("Get(app) = %v, want test", ls.Get("app"))
}
if ls.Get("env") != "prod" {
t.Errorf("Get(env) = %v, want prod", ls.Get("env"))
}
if ls.Get("nonexistent") != "" {
t.Errorf("Get(nonexistent) = %v, want empty string", ls.Get("nonexistent"))
}
}
func TestSecretProviderClassPodStatusPredicates(t *testing.T) {
cfg := config.NewDefault()
p := SecretProviderClassPodStatusPredicates(cfg, NewHasher())
oldObj := &csiv1.SecretProviderClassPodStatus{
Status: csiv1.SecretProviderClassPodStatusStatus{
SecretProviderClassName: "spc",
Objects: []csiv1.SecretProviderClassObject{{ID: "a", Version: "1"}},
},
}
newObjChanged := &csiv1.SecretProviderClassPodStatus{
Status: csiv1.SecretProviderClassPodStatusStatus{
SecretProviderClassName: "spc",
Objects: []csiv1.SecretProviderClassObject{{ID: "a", Version: "2"}},
},
}
newObjSame := oldObj.DeepCopy()
// Create and Delete are always ignored for SPCPS.
if p.Create(event.CreateEvent{Object: oldObj}) {
t.Fatal("CreateFunc should return false")
}
if p.Delete(event.DeleteEvent{Object: oldObj}) {
t.Fatal("DeleteFunc should return false")
}
// Update only when the status hash changes.
if !p.Update(event.UpdateEvent{ObjectOld: oldObj, ObjectNew: newObjChanged}) {
t.Fatal("UpdateFunc should return true on changed status")
}
if p.Update(event.UpdateEvent{ObjectOld: oldObj, ObjectNew: newObjSame}) {
t.Fatal("UpdateFunc should return false on unchanged status")
}
// A metadata/label-only change (same Status) must NOT trigger a reload,
// since the predicate hashes only the status. (Master tested this via
// UpdateSecretProviderClassPodStatusLabels.)
labelOnly := oldObj.DeepCopy()
labelOnly.Labels = map[string]string{"unrelated": "changed"}
labelOnly.Annotations = map[string]string{"note": "touched"}
if p.Update(event.UpdateEvent{ObjectOld: oldObj, ObjectNew: labelOnly}) {
t.Fatal("UpdateFunc should return false on a label/metadata-only change")
}
// Type-assertion failure (wrong object type) must be rejected, not panic.
if p.Update(event.UpdateEvent{ObjectOld: &corev1.ConfigMap{}, ObjectNew: &corev1.ConfigMap{}}) {
t.Fatal("UpdateFunc should return false when objects are not SPCPS")
}
}
-333
View File
@@ -1,333 +0,0 @@
package reload
import (
"context"
"encoding/json"
"fmt"
"time"
"github.com/go-logr/logr"
corev1 "k8s.io/api/core/v1"
"github.com/stakater/Reloader/internal/pkg/workload"
"github.com/stakater/Reloader/pkg/config"
)
// Service orchestrates the reload logic for ConfigMaps and Secrets.
//
// NOTE: not part of the public API — its methods reference internal/pkg/workload
// and are therefore not usable from outside this module. External, decision-only
// consumers should use Matcher instead.
type Service struct {
cfg *config.Config
log logr.Logger
hasher *Hasher
matcher *Matcher
strategy Strategy
}
// NewService creates a new reload Service with the given configuration.
func NewService(cfg *config.Config, log logr.Logger) *Service {
return &Service{
cfg: cfg,
log: log,
hasher: NewHasher(),
matcher: NewMatcher(cfg),
strategy: NewStrategy(cfg),
}
}
// Process evaluates all workloads to determine which should be reloaded.
func (s *Service) Process(change ResourceChange, workloads []workload.Workload) []ReloadDecision {
if change.IsNil() {
return nil
}
if !s.shouldProcessEvent(change.GetEventType()) {
return nil
}
hash := change.ComputeHash(s.hasher)
if change.GetEventType() == EventTypeDelete {
hash = s.hasher.EmptyHash()
}
return s.processResource(
change.GetName(),
change.GetNamespace(),
change.GetAnnotations(),
change.GetResourceType(),
hash,
workloads,
)
}
func (s *Service) processResource(
resourceName string,
resourceNamespace string,
resourceAnnotations map[string]string,
resourceType ResourceType,
hash string,
workloads []workload.Workload,
) []ReloadDecision {
var decisions []ReloadDecision
for _, wl := range workloads {
if wl.GetNamespace() != resourceNamespace {
continue
}
if s.cfg.IsWorkloadIgnored(string(wl.Kind())) {
continue
}
var usesResource bool
switch resourceType {
case ResourceTypeConfigMap:
usesResource = wl.UsesConfigMap(resourceName)
case ResourceTypeSecret:
usesResource = wl.UsesSecret(resourceName)
case ResourceTypeSecretProviderClass:
// Annotation-only matching (parity with master): the workload's
// annotations alone decide the reload; no volume-uses scan.
usesResource = true
}
input := MatchInput{
ResourceName: resourceName,
ResourceNamespace: resourceNamespace,
ResourceType: resourceType,
ResourceAnnotations: resourceAnnotations,
WorkloadAnnotations: wl.GetAnnotations(),
PodAnnotations: wl.GetPodTemplateAnnotations(),
}
matchResult := s.matcher.ShouldReload(input)
shouldReload := matchResult.ShouldReload
if matchResult.AutoReload && !usesResource {
shouldReload = false
}
decisions = append(
decisions, ReloadDecision{
Workload: wl,
ShouldReload: shouldReload,
AutoReload: matchResult.AutoReload,
Reason: matchResult.Reason,
Hash: hash,
},
)
}
return decisions
}
func (s *Service) shouldProcessEvent(eventType EventType) bool {
switch eventType {
case EventTypeCreate:
return s.cfg.ReloadOnCreate
case EventTypeDelete:
return s.cfg.ReloadOnDelete
case EventTypeUpdate:
return true
default:
return false
}
}
// ApplyReload applies the reload strategy to a workload.
func (s *Service) ApplyReload(
ctx context.Context,
wl workload.Workload,
resourceName string,
resourceType ResourceType,
namespace string,
hash string,
autoReload bool,
) (bool, error) {
container := s.findTargetContainer(wl, resourceName, resourceType, autoReload)
input := StrategyInput{
ResourceName: resourceName,
ResourceType: resourceType,
Namespace: namespace,
Hash: hash,
Container: container,
PodAnnotations: wl.GetPodTemplateAnnotations(),
AutoReload: autoReload,
}
updated, err := s.strategy.Apply(input)
if err != nil {
return false, err
}
if updated {
// Attribution annotation is informational; log errors but don't fail reloads
if err := s.setAttributionAnnotation(wl, resourceName, resourceType, namespace, hash, container); err != nil {
s.log.V(1).Info("failed to set attribution annotation", "error", err, "workload", wl.GetName())
}
}
return updated, nil
}
func (s *Service) setAttributionAnnotation(
wl workload.Workload,
resourceName string,
resourceType ResourceType,
namespace string,
hash string,
container *corev1.Container,
) error {
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 {
return fmt.Errorf("failed to marshal reload source: %w", err)
}
wl.SetPodTemplateAnnotation(s.cfg.Annotations.LastReloadedFrom, string(sourceJSON))
return nil
}
func (s *Service) findTargetContainer(
wl workload.Workload,
resourceName string,
resourceType ResourceType,
autoReload bool,
) *corev1.Container {
containers := wl.GetContainers()
if len(containers) == 0 {
return nil
}
if !autoReload {
return &containers[0]
}
volumes := wl.GetVolumes()
initContainers := wl.GetInitContainers()
volumeName := s.findVolumeUsingResource(volumes, resourceName, resourceType)
if volumeName != "" {
container := s.findContainerWithVolumeMount(containers, volumeName)
if container != nil {
return container
}
container = s.findContainerWithVolumeMount(initContainers, volumeName)
if container != nil {
return &containers[0]
}
}
container := s.findContainerWithEnvRef(containers, resourceName, resourceType)
if container != nil {
return container
}
container = s.findContainerWithEnvRef(initContainers, resourceName, resourceType)
if container != nil {
return &containers[0]
}
return &containers[0]
}
func (s *Service) findVolumeUsingResource(volumes []corev1.Volume, resourceName string, resourceType ResourceType) string {
for _, vol := range volumes {
switch resourceType {
case ResourceTypeConfigMap:
if vol.ConfigMap != nil && vol.ConfigMap.Name == resourceName {
return vol.Name
}
if vol.Projected != nil {
for _, src := range vol.Projected.Sources {
if src.ConfigMap != nil && src.ConfigMap.Name == resourceName {
return vol.Name
}
}
}
case ResourceTypeSecret:
if vol.Secret != nil && vol.Secret.SecretName == resourceName {
return vol.Name
}
if vol.Projected != nil {
for _, src := range vol.Projected.Sources {
if src.Secret != nil && src.Secret.Name == resourceName {
return vol.Name
}
}
}
case ResourceTypeSecretProviderClass:
// Match the CSI volume that references this SPC.
if vol.CSI != nil && vol.CSI.VolumeAttributes["secretProviderClass"] == resourceName {
return vol.Name
}
}
}
return ""
}
func (s *Service) findContainerWithVolumeMount(containers []corev1.Container, volumeName string) *corev1.Container {
for i := range containers {
for _, mount := range containers[i].VolumeMounts {
if mount.Name == volumeName {
return &containers[i]
}
}
}
return nil
}
func (s *Service) findContainerWithEnvRef(containers []corev1.Container, resourceName string, resourceType ResourceType) *corev1.Container {
for i := range containers {
for _, env := range containers[i].Env {
if env.ValueFrom == nil {
continue
}
switch resourceType {
case ResourceTypeConfigMap:
if env.ValueFrom.ConfigMapKeyRef != nil && env.ValueFrom.ConfigMapKeyRef.Name == resourceName {
return &containers[i]
}
case ResourceTypeSecret:
if env.ValueFrom.SecretKeyRef != nil && env.ValueFrom.SecretKeyRef.Name == resourceName {
return &containers[i]
}
}
}
for _, envFrom := range containers[i].EnvFrom {
switch resourceType {
case ResourceTypeConfigMap:
if envFrom.ConfigMapRef != nil && envFrom.ConfigMapRef.Name == resourceName {
return &containers[i]
}
case ResourceTypeSecret:
if envFrom.SecretRef != nil && envFrom.SecretRef.Name == resourceName {
return &containers[i]
}
}
}
}
return nil
}
// Hasher returns the hasher used by this service.
func (s *Service) Hasher() *Hasher {
return s.hasher
}
File diff suppressed because it is too large Load Diff
-205
View File
@@ -1,205 +0,0 @@
package reload
import (
"bytes"
"encoding/json"
"fmt"
"strings"
"time"
corev1 "k8s.io/api/core/v1"
"github.com/stakater/Reloader/pkg/config"
)
const (
// EnvVarPrefix is the prefix for environment variables added by Reloader.
EnvVarPrefix = "STAKATER_"
// ConfigmapEnvVarPostfix is the postfix for ConfigMap environment variables.
ConfigmapEnvVarPostfix = "CONFIGMAP"
// SecretEnvVarPostfix is the postfix for Secret environment variables.
SecretEnvVarPostfix = "SECRET"
// SecretProviderClassEnvVarPostfix is the postfix for SecretProviderClass environment variables.
SecretProviderClassEnvVarPostfix = "SECRETPROVIDERCLASS"
)
// Strategy defines how workload restarts are triggered.
type Strategy interface {
Apply(input StrategyInput) (bool, error)
Name() string
}
// StrategyInput contains the information needed to apply a reload strategy.
type StrategyInput struct {
ResourceName string
ResourceType ResourceType
Namespace string
Hash string
Container *corev1.Container
PodAnnotations map[string]string
AutoReload bool
}
// ReloadSource contains metadata about what triggered a reload.
type ReloadSource struct {
Kind string `json:"kind"`
Name string `json:"name"`
Namespace string `json:"namespace"`
Hash string `json:"hash"`
Containers []string `json:"containers"`
ReloadedAt time.Time `json:"reloadedAt"`
}
// EnvVarStrategy triggers reloads by adding/updating environment variables.
type EnvVarStrategy struct{}
// NewEnvVarStrategy creates a new EnvVarStrategy.
func NewEnvVarStrategy() *EnvVarStrategy {
return &EnvVarStrategy{}
}
func (s *EnvVarStrategy) Name() string {
return string(config.ReloadStrategyEnvVars)
}
// Apply adds, updates, or removes an environment variable to trigger a restart.
func (s *EnvVarStrategy) Apply(input StrategyInput) (bool, error) {
if input.Container == nil {
return false, fmt.Errorf("container is required for env-var strategy")
}
envVarName := s.envVarName(input.ResourceName, input.ResourceType)
if input.Hash == "" {
return s.removeEnvVar(input.Container, envVarName), nil
}
for i := range input.Container.Env {
if input.Container.Env[i].Name == envVarName {
if input.Container.Env[i].Value == input.Hash {
return false, nil
}
input.Container.Env[i].Value = input.Hash
return true, nil
}
}
input.Container.Env = append(input.Container.Env, corev1.EnvVar{
Name: envVarName,
Value: input.Hash,
})
return true, nil
}
func (s *EnvVarStrategy) removeEnvVar(container *corev1.Container, name string) bool {
for i := range container.Env {
if container.Env[i].Name == name {
container.Env[i] = container.Env[len(container.Env)-1]
container.Env = container.Env[:len(container.Env)-1]
return true
}
}
return false
}
func (s *EnvVarStrategy) envVarName(resourceName string, resourceType ResourceType) string {
var postfix string
switch resourceType {
case ResourceTypeConfigMap:
postfix = ConfigmapEnvVarPostfix
case ResourceTypeSecret:
postfix = SecretEnvVarPostfix
case ResourceTypeSecretProviderClass:
postfix = SecretProviderClassEnvVarPostfix
}
return EnvVarPrefix + convertToEnvVarName(resourceName) + "_" + postfix
}
func convertToEnvVarName(text string) string {
var buffer bytes.Buffer
upper := strings.ToUpper(text)
lastCharValid := false
for i := 0; i < len(upper); i++ {
ch := upper[i]
if (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') {
buffer.WriteByte(ch)
lastCharValid = true
} else {
if lastCharValid {
buffer.WriteByte('_')
}
lastCharValid = false
}
}
return buffer.String()
}
// AnnotationStrategy triggers reloads by adding/updating pod template annotations.
type AnnotationStrategy struct {
cfg *config.Config
}
// NewAnnotationStrategy creates a new AnnotationStrategy.
func NewAnnotationStrategy(cfg *config.Config) *AnnotationStrategy {
return &AnnotationStrategy{cfg: cfg}
}
func (s *AnnotationStrategy) Name() string {
return string(config.ReloadStrategyAnnotations)
}
// Apply adds or updates a pod annotation to trigger a restart.
func (s *AnnotationStrategy) Apply(input StrategyInput) (bool, error) {
if input.PodAnnotations == nil {
return false, fmt.Errorf("pod annotations map is required for annotation strategy")
}
containerName := ""
if input.Container != nil {
containerName = input.Container.Name
}
source := ReloadSource{
Kind: string(input.ResourceType),
Name: input.ResourceName,
Namespace: input.Namespace,
Hash: input.Hash,
Containers: []string{containerName},
ReloadedAt: time.Now().UTC(),
}
sourceJSON, err := json.Marshal(source)
if err != nil {
return false, fmt.Errorf("failed to marshal reload source: %w", err)
}
annotationKey := s.cfg.Annotations.LastReloadedFrom
existingValue := input.PodAnnotations[annotationKey]
// Idempotent on kind+name+hash, ignoring ReloadedAt: a timestamped compare
// would force a rollout every reconcile (one CSI rotation fans out to N
// SecretProviderClassPodStatus updates → N rollouts).
if existingValue != "" {
var prev ReloadSource
if err := json.Unmarshal([]byte(existingValue), &prev); err == nil &&
prev.Kind == source.Kind && prev.Name == source.Name && prev.Hash == source.Hash {
return false, nil
}
}
input.PodAnnotations[annotationKey] = string(sourceJSON)
return true, nil
}
// NewStrategy creates a Strategy based on the configuration.
func NewStrategy(cfg *config.Config) Strategy {
switch cfg.ReloadStrategy {
case config.ReloadStrategyAnnotations:
return NewAnnotationStrategy(cfg)
default:
return NewEnvVarStrategy()
}
}
-346
View File
@@ -1,346 +0,0 @@
package reload
import (
"encoding/json"
"testing"
corev1 "k8s.io/api/core/v1"
"github.com/stakater/Reloader/pkg/config"
)
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("idempotent for same resource and hash (ignores timestamp)", func(t *testing.T) {
annotations := make(map[string]string)
input := StrategyInput{
ResourceName: "my-config",
ResourceType: ResourceTypeConfigMap,
Namespace: "default",
Hash: "abc123",
Container: &corev1.Container{Name: "c"},
PodAnnotations: annotations,
}
changed, err := strategy.Apply(input)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !changed {
t.Fatal("expected changed=true on first apply")
}
firstValue := annotations[cfg.Annotations.LastReloadedFrom]
// Re-applying the identical change must NOT report a change, even though
// a fresh ReloadedAt timestamp would make the serialized value differ.
changed, err = strategy.Apply(input)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if changed {
t.Error("expected changed=false when re-applying the same resource+hash")
}
if annotations[cfg.Annotations.LastReloadedFrom] != firstValue {
t.Error("annotation value must not change on an idempotent re-apply")
}
// A different hash (real content change) must trigger an update.
input.Hash = "def456"
changed, err = strategy.Apply(input)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !changed {
t.Error("expected changed=true when the hash changes")
}
})
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())
}
})
}
func TestEnvVarNameSecretProviderClass(t *testing.T) {
s := NewEnvVarStrategy()
got := s.envVarName("my-vault-spc", ResourceTypeSecretProviderClass)
want := "STAKATER_MY_VAULT_SPC_SECRETPROVIDERCLASS"
if got != want {
t.Fatalf("envVarName = %q, want %q", got, want)
}
}