mirror of
https://github.com/stakater/Reloader.git
synced 2026-08-21 04:56:36 +00:00
refactor: extract matcher into separate package
This commit is contained in:
@@ -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"
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user