mirror of
https://github.com/stakater/Reloader.git
synced 2026-08-20 04:26:28 +00:00
feat: Add reload package with core matching and strategy logic
This commit is contained in:
@@ -0,0 +1,85 @@
|
||||
// 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"
|
||||
)
|
||||
|
||||
// Hasher computes content hashes for ConfigMaps and Secrets.
|
||||
// The hash is used to detect changes and trigger workload reloads.
|
||||
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.
|
||||
// The hash is deterministic - same content always produces the same hash.
|
||||
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.
|
||||
// The hash is deterministic - same content always produces the same hash.
|
||||
func (h *Hasher) HashSecret(secret *corev1.Secret) string {
|
||||
if secret == nil {
|
||||
return h.computeSHA("")
|
||||
}
|
||||
return h.hashSecretData(secret.Data)
|
||||
}
|
||||
|
||||
// hashConfigMapData computes a hash from ConfigMap data and binary data.
|
||||
// Keys are sorted to ensure deterministic output.
|
||||
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 {
|
||||
// Binary data is base64 encoded for consistent hashing
|
||||
values = append(values, k+"="+base64.StdEncoding.EncodeToString(v))
|
||||
}
|
||||
|
||||
sort.Strings(values)
|
||||
return h.computeSHA(strings.Join(values, ";"))
|
||||
}
|
||||
|
||||
// hashSecretData computes a hash from Secret data.
|
||||
// Keys are sorted to ensure deterministic output.
|
||||
func (h *Hasher) hashSecretData(data map[string][]byte) string {
|
||||
values := make([]string, 0, len(data))
|
||||
|
||||
for k, v := range data {
|
||||
// Secret data is stored as raw bytes, not base64 encoded
|
||||
values = append(values, k+"="+string(v))
|
||||
}
|
||||
|
||||
sort.Strings(values)
|
||||
return h.computeSHA(strings.Join(values, ";"))
|
||||
}
|
||||
|
||||
// computeSHA generates a SHA1 hash from a string.
|
||||
func (h *Hasher) computeSHA(data string) string {
|
||||
hasher := sha1.New()
|
||||
_, _ = io.WriteString(hasher, data)
|
||||
return fmt.Sprintf("%x", hasher.Sum(nil))
|
||||
}
|
||||
|
||||
// EmptyHash returns the hash of empty content.
|
||||
// This is useful for comparison when resources are deleted.
|
||||
func (h *Hasher) EmptyHash() string {
|
||||
return h.computeSHA("")
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
package reload
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/stakater/Reloader/internal/pkg/config"
|
||||
)
|
||||
|
||||
// ResourceType represents the type of Kubernetes resource.
|
||||
type ResourceType string
|
||||
|
||||
const (
|
||||
// ResourceTypeConfigMap represents a ConfigMap resource.
|
||||
ResourceTypeConfigMap ResourceType = "configmap"
|
||||
// ResourceTypeSecret represents a Secret resource.
|
||||
ResourceTypeSecret ResourceType = "secret"
|
||||
)
|
||||
|
||||
// MatchResult contains the result of checking if a workload should be reloaded.
|
||||
type MatchResult struct {
|
||||
// ShouldReload indicates whether the workload should be reloaded.
|
||||
ShouldReload bool
|
||||
// AutoReload indicates if this is an auto-reload (vs explicit annotation).
|
||||
// This affects which container to target for env var injection.
|
||||
AutoReload bool
|
||||
// Reason provides a human-readable explanation of the decision.
|
||||
Reason string
|
||||
}
|
||||
|
||||
// Matcher determines whether a workload should be reloaded based on annotations.
|
||||
type Matcher struct {
|
||||
cfg *config.Config
|
||||
}
|
||||
|
||||
// NewMatcher creates a new Matcher with the given configuration.
|
||||
func NewMatcher(cfg *config.Config) *Matcher {
|
||||
return &Matcher{cfg: cfg}
|
||||
}
|
||||
|
||||
// MatchInput contains all the information needed to determine if a reload should occur.
|
||||
type MatchInput struct {
|
||||
// ResourceName is the name of the ConfigMap or Secret that changed.
|
||||
ResourceName string
|
||||
// ResourceNamespace is the namespace of the ConfigMap or Secret.
|
||||
ResourceNamespace string
|
||||
// ResourceType is whether this is a ConfigMap or Secret.
|
||||
ResourceType ResourceType
|
||||
// ResourceAnnotations are the annotations on the ConfigMap or Secret.
|
||||
ResourceAnnotations map[string]string
|
||||
// WorkloadAnnotations are the annotations on the workload (Deployment, etc.).
|
||||
WorkloadAnnotations map[string]string
|
||||
// PodAnnotations are the annotations on the pod template.
|
||||
PodAnnotations map[string]string
|
||||
}
|
||||
|
||||
// ShouldReload determines if a workload should be reloaded based on its annotations.
|
||||
//
|
||||
// The matching logic follows this precedence (BUG FIX: explicit annotations checked first):
|
||||
// 1. If the resource has the ignore annotation, skip it
|
||||
// 2. If the resource is in the exclude list for this workload, skip it
|
||||
// 3. If explicit reload annotation matches the resource name, reload (not auto)
|
||||
// 4. If search annotation is enabled and resource has match annotation, reload (auto)
|
||||
// 5. If auto annotation is "true", reload (auto)
|
||||
// 6. If typed auto annotation is "true", reload (auto)
|
||||
// 7. If AutoReloadAll is enabled and no explicit "false" annotations, reload (auto)
|
||||
func (m *Matcher) ShouldReload(input MatchInput) MatchResult {
|
||||
// Check resource-level ignore annotation
|
||||
if m.isResourceIgnored(input.ResourceAnnotations) {
|
||||
return MatchResult{
|
||||
ShouldReload: false,
|
||||
Reason: "resource has ignore annotation",
|
||||
}
|
||||
}
|
||||
|
||||
// Determine which annotations to use (workload or pod template)
|
||||
annotations := m.selectAnnotations(input)
|
||||
|
||||
// Check if resource is excluded
|
||||
if m.isResourceExcluded(input.ResourceName, input.ResourceType, annotations) {
|
||||
return MatchResult{
|
||||
ShouldReload: false,
|
||||
Reason: "resource is in exclude list",
|
||||
}
|
||||
}
|
||||
|
||||
// Check explicit reload annotation (e.g., configmap.reloader.stakater.com/reload: "my-config")
|
||||
// BUG FIX: Check this BEFORE auto annotations to ensure explicit references take precedence
|
||||
if m.matchesExplicitAnnotation(input.ResourceName, input.ResourceType, annotations) {
|
||||
return MatchResult{
|
||||
ShouldReload: true,
|
||||
AutoReload: false,
|
||||
Reason: "matches explicit reload annotation",
|
||||
}
|
||||
}
|
||||
|
||||
// Check search/match pattern
|
||||
if m.matchesSearchPattern(input.ResourceAnnotations, annotations) {
|
||||
return MatchResult{
|
||||
ShouldReload: true,
|
||||
AutoReload: true,
|
||||
Reason: "matches search/match pattern",
|
||||
}
|
||||
}
|
||||
|
||||
// Check auto annotations
|
||||
if m.matchesAutoAnnotation(input.ResourceType, annotations) {
|
||||
return MatchResult{
|
||||
ShouldReload: true,
|
||||
AutoReload: true,
|
||||
Reason: "auto annotation enabled",
|
||||
}
|
||||
}
|
||||
|
||||
// Check global auto-reload-all setting
|
||||
if m.matchesAutoReloadAll(input.ResourceType, annotations) {
|
||||
return MatchResult{
|
||||
ShouldReload: true,
|
||||
AutoReload: true,
|
||||
Reason: "auto-reload-all enabled",
|
||||
}
|
||||
}
|
||||
|
||||
return MatchResult{
|
||||
ShouldReload: false,
|
||||
Reason: "no matching annotations",
|
||||
}
|
||||
}
|
||||
|
||||
// isResourceIgnored checks if the resource has the ignore annotation set to true.
|
||||
func (m *Matcher) isResourceIgnored(resourceAnnotations map[string]string) bool {
|
||||
if resourceAnnotations == nil {
|
||||
return false
|
||||
}
|
||||
return resourceAnnotations[m.cfg.Annotations.Ignore] == "true"
|
||||
}
|
||||
|
||||
// selectAnnotations determines which set of annotations to use for matching.
|
||||
// If workload annotations don't have relevant annotations, fall back to pod annotations.
|
||||
func (m *Matcher) selectAnnotations(input MatchInput) map[string]string {
|
||||
// Check if any relevant annotation exists on workload annotations
|
||||
if m.hasRelevantAnnotations(input.WorkloadAnnotations, input.ResourceType) {
|
||||
return input.WorkloadAnnotations
|
||||
}
|
||||
// Fall back to pod annotations
|
||||
if m.hasRelevantAnnotations(input.PodAnnotations, input.ResourceType) {
|
||||
return input.PodAnnotations
|
||||
}
|
||||
// Default to workload annotations even if empty
|
||||
return input.WorkloadAnnotations
|
||||
}
|
||||
|
||||
// hasRelevantAnnotations checks if the annotations contain any reload-related annotation.
|
||||
func (m *Matcher) hasRelevantAnnotations(annotations map[string]string, resourceType ResourceType) bool {
|
||||
if annotations == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check for explicit annotation
|
||||
explicitAnn := m.getExplicitAnnotation(resourceType)
|
||||
if _, ok := annotations[explicitAnn]; ok {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check for search annotation
|
||||
if _, ok := annotations[m.cfg.Annotations.Search]; ok {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check for auto annotation
|
||||
if _, ok := annotations[m.cfg.Annotations.Auto]; ok {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check for typed auto annotation
|
||||
typedAutoAnn := m.getTypedAutoAnnotation(resourceType)
|
||||
if _, ok := annotations[typedAutoAnn]; ok {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// isResourceExcluded checks if the resource is in the exclude list.
|
||||
func (m *Matcher) isResourceExcluded(resourceName string, resourceType ResourceType, annotations map[string]string) bool {
|
||||
if annotations == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
var excludeAnn string
|
||||
switch resourceType {
|
||||
case ResourceTypeConfigMap:
|
||||
excludeAnn = m.cfg.Annotations.ConfigmapExclude
|
||||
case ResourceTypeSecret:
|
||||
excludeAnn = m.cfg.Annotations.SecretExclude
|
||||
}
|
||||
|
||||
excludeList, ok := annotations[excludeAnn]
|
||||
if !ok || excludeList == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
for _, excluded := range strings.Split(excludeList, ",") {
|
||||
if strings.TrimSpace(excluded) == resourceName {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// matchesExplicitAnnotation checks if the resource name matches the explicit reload annotation.
|
||||
func (m *Matcher) matchesExplicitAnnotation(resourceName string, resourceType ResourceType, annotations map[string]string) bool {
|
||||
if annotations == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
explicitAnn := m.getExplicitAnnotation(resourceType)
|
||||
annotationValue, ok := annotations[explicitAnn]
|
||||
if !ok || annotationValue == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
// Support comma-separated list of resource names with regex matching
|
||||
for _, value := range strings.Split(annotationValue, ",") {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
// Support regex patterns
|
||||
re, err := regexp.Compile("^" + value + "$")
|
||||
if err != nil {
|
||||
// If regex is invalid, fall back to exact match
|
||||
if value == resourceName {
|
||||
return true
|
||||
}
|
||||
continue
|
||||
}
|
||||
if re.MatchString(resourceName) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// matchesSearchPattern checks if the search/match pattern is enabled.
|
||||
func (m *Matcher) matchesSearchPattern(resourceAnnotations, workloadAnnotations map[string]string) bool {
|
||||
if workloadAnnotations == nil || resourceAnnotations == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
searchValue, ok := workloadAnnotations[m.cfg.Annotations.Search]
|
||||
if !ok || searchValue != "true" {
|
||||
return false
|
||||
}
|
||||
|
||||
matchValue, ok := resourceAnnotations[m.cfg.Annotations.Match]
|
||||
return ok && matchValue == "true"
|
||||
}
|
||||
|
||||
// matchesAutoAnnotation checks if auto reload is enabled via annotations.
|
||||
func (m *Matcher) matchesAutoAnnotation(resourceType ResourceType, annotations map[string]string) bool {
|
||||
if annotations == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check generic auto annotation
|
||||
if annotations[m.cfg.Annotations.Auto] == "true" {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check typed auto annotation
|
||||
typedAutoAnn := m.getTypedAutoAnnotation(resourceType)
|
||||
return annotations[typedAutoAnn] == "true"
|
||||
}
|
||||
|
||||
// matchesAutoReloadAll checks if global auto-reload-all is enabled.
|
||||
func (m *Matcher) matchesAutoReloadAll(resourceType ResourceType, annotations map[string]string) bool {
|
||||
if !m.cfg.AutoReloadAll {
|
||||
return false
|
||||
}
|
||||
|
||||
// If auto annotation is explicitly set to false, don't auto-reload
|
||||
if annotations != nil {
|
||||
if annotations[m.cfg.Annotations.Auto] == "false" {
|
||||
return false
|
||||
}
|
||||
typedAutoAnn := m.getTypedAutoAnnotation(resourceType)
|
||||
if annotations[typedAutoAnn] == "false" {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// getExplicitAnnotation returns the explicit reload annotation for the resource type.
|
||||
func (m *Matcher) getExplicitAnnotation(resourceType ResourceType) string {
|
||||
switch resourceType {
|
||||
case ResourceTypeConfigMap:
|
||||
return m.cfg.Annotations.ConfigmapReload
|
||||
case ResourceTypeSecret:
|
||||
return m.cfg.Annotations.SecretReload
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// getTypedAutoAnnotation returns the typed auto annotation for the resource type.
|
||||
func (m *Matcher) getTypedAutoAnnotation(resourceType ResourceType) string {
|
||||
switch resourceType {
|
||||
case ResourceTypeConfigMap:
|
||||
return m.cfg.Annotations.ConfigmapAuto
|
||||
case ResourceTypeSecret:
|
||||
return m.cfg.Annotations.SecretAuto
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
package reload
|
||||
|
||||
import (
|
||||
"github.com/stakater/Reloader/internal/pkg/config"
|
||||
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"
|
||||
)
|
||||
|
||||
// ConfigMapPredicates returns predicates for filtering ConfigMap events.
|
||||
func ConfigMapPredicates(cfg *config.Config, hasher *Hasher) predicate.Predicate {
|
||||
return predicate.Funcs{
|
||||
CreateFunc: func(e event.CreateEvent) bool {
|
||||
// Only process create events if ReloadOnCreate is enabled
|
||||
// or if SyncAfterRestart is enabled (for initial sync)
|
||||
return cfg.ReloadOnCreate || cfg.SyncAfterRestart
|
||||
},
|
||||
UpdateFunc: func(e event.UpdateEvent) bool {
|
||||
// Always process updates, but filter by content change
|
||||
oldCM, okOld := e.ObjectOld.(*corev1.ConfigMap)
|
||||
newCM, okNew := e.ObjectNew.(*corev1.ConfigMap)
|
||||
if !okOld || !okNew {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if the data actually changed
|
||||
oldHash := hasher.HashConfigMap(oldCM)
|
||||
newHash := hasher.HashConfigMap(newCM)
|
||||
return oldHash != newHash
|
||||
},
|
||||
DeleteFunc: func(e event.DeleteEvent) bool {
|
||||
// Only process delete events if ReloadOnDelete is enabled
|
||||
return cfg.ReloadOnDelete
|
||||
},
|
||||
GenericFunc: func(e event.GenericEvent) bool {
|
||||
// Ignore generic events
|
||||
return false
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// SecretPredicates returns predicates for filtering Secret events.
|
||||
func SecretPredicates(cfg *config.Config, hasher *Hasher) predicate.Predicate {
|
||||
return predicate.Funcs{
|
||||
CreateFunc: func(e event.CreateEvent) bool {
|
||||
// Only process create events if ReloadOnCreate is enabled
|
||||
// or if SyncAfterRestart is enabled (for initial sync)
|
||||
return cfg.ReloadOnCreate || cfg.SyncAfterRestart
|
||||
},
|
||||
UpdateFunc: func(e event.UpdateEvent) bool {
|
||||
// Always process updates, but filter by content change
|
||||
oldSecret, okOld := e.ObjectOld.(*corev1.Secret)
|
||||
newSecret, okNew := e.ObjectNew.(*corev1.Secret)
|
||||
if !okOld || !okNew {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if the data actually changed
|
||||
oldHash := hasher.HashSecret(oldSecret)
|
||||
newHash := hasher.HashSecret(newSecret)
|
||||
return oldHash != newHash
|
||||
},
|
||||
DeleteFunc: func(e event.DeleteEvent) bool {
|
||||
// Only process delete events if ReloadOnDelete is enabled
|
||||
return cfg.ReloadOnDelete
|
||||
},
|
||||
GenericFunc: func(e event.GenericEvent) bool {
|
||||
// Ignore generic events
|
||||
return false
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// NamespaceFilterPredicate returns a predicate that filters resources by namespace.
|
||||
func NamespaceFilterPredicate(cfg *config.Config) predicate.Predicate {
|
||||
return predicate.NewPredicateFuncs(func(obj client.Object) bool {
|
||||
namespace := obj.GetNamespace()
|
||||
|
||||
// Check if namespace should be ignored
|
||||
if cfg.IsNamespaceIgnored(namespace) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check namespace selectors
|
||||
// Note: For now, we pass through and let the controller handle selector matching
|
||||
// A more efficient implementation would check labels here
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
// LabelSelectorPredicate returns a predicate that filters resources by labels.
|
||||
func LabelSelectorPredicate(cfg *config.Config) predicate.Predicate {
|
||||
if len(cfg.ResourceSelectors) == 0 {
|
||||
// No selectors configured, allow all
|
||||
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)
|
||||
}
|
||||
|
||||
// Check if any selector matches
|
||||
for _, selector := range cfg.ResourceSelectors {
|
||||
if selector.Matches(labelsSet(labels)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
})
|
||||
}
|
||||
|
||||
// labelsSet implements labels.Labels interface for a map.
|
||||
type labelsSet map[string]string
|
||||
|
||||
func (ls labelsSet) Has(key string) bool {
|
||||
_, ok := ls[key]
|
||||
return ok
|
||||
}
|
||||
|
||||
func (ls labelsSet) Get(key string) string {
|
||||
return ls[key]
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// Check for ignore annotation
|
||||
return annotations[cfg.Annotations.Ignore] != "true"
|
||||
})
|
||||
}
|
||||
|
||||
// CombinedPredicates combines multiple predicates with AND logic.
|
||||
func CombinedPredicates(predicates ...predicate.Predicate) predicate.Predicate {
|
||||
return predicate.And(predicates...)
|
||||
}
|
||||
@@ -0,0 +1,401 @@
|
||||
package reload
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/stakater/Reloader/internal/pkg/config"
|
||||
"github.com/stakater/Reloader/internal/pkg/workload"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||
)
|
||||
|
||||
// Service orchestrates the reload logic for ConfigMaps and Secrets.
|
||||
type Service struct {
|
||||
cfg *config.Config
|
||||
hasher *Hasher
|
||||
matcher *Matcher
|
||||
strategy Strategy
|
||||
}
|
||||
|
||||
// NewService creates a new reload Service with the given configuration.
|
||||
func NewService(cfg *config.Config) *Service {
|
||||
return &Service{
|
||||
cfg: cfg,
|
||||
hasher: NewHasher(),
|
||||
matcher: NewMatcher(cfg),
|
||||
strategy: NewStrategy(cfg),
|
||||
}
|
||||
}
|
||||
|
||||
// ConfigMapChange represents a change event for a ConfigMap.
|
||||
type ConfigMapChange struct {
|
||||
ConfigMap *corev1.ConfigMap
|
||||
EventType EventType
|
||||
}
|
||||
|
||||
// SecretChange represents a change event for a Secret.
|
||||
type SecretChange struct {
|
||||
Secret *corev1.Secret
|
||||
EventType EventType
|
||||
}
|
||||
|
||||
// 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"
|
||||
)
|
||||
|
||||
// ReloadDecision contains the result of evaluating whether to reload a workload.
|
||||
type ReloadDecision struct {
|
||||
// Workload is the workload accessor.
|
||||
Workload workload.WorkloadAccessor
|
||||
// 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
|
||||
}
|
||||
|
||||
// ProcessConfigMap evaluates all workloads to determine which should be reloaded.
|
||||
// This method does not modify any workloads - it only returns decisions.
|
||||
func (s *Service) ProcessConfigMap(change ConfigMapChange, workloads []workload.WorkloadAccessor) []ReloadDecision {
|
||||
if change.ConfigMap == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check if we should process this event type
|
||||
if !s.shouldProcessEvent(change.EventType) {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Compute hash
|
||||
hash := s.hasher.HashConfigMap(change.ConfigMap)
|
||||
if change.EventType == EventTypeDelete {
|
||||
hash = s.hasher.EmptyHash()
|
||||
}
|
||||
|
||||
return s.processResource(
|
||||
change.ConfigMap.Name,
|
||||
change.ConfigMap.Namespace,
|
||||
change.ConfigMap.Annotations,
|
||||
ResourceTypeConfigMap,
|
||||
hash,
|
||||
workloads,
|
||||
)
|
||||
}
|
||||
|
||||
// ProcessSecret evaluates all workloads to determine which should be reloaded.
|
||||
// This method does not modify any workloads - it only returns decisions.
|
||||
func (s *Service) ProcessSecret(change SecretChange, workloads []workload.WorkloadAccessor) []ReloadDecision {
|
||||
if change.Secret == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check if we should process this event type
|
||||
if !s.shouldProcessEvent(change.EventType) {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Compute hash
|
||||
hash := s.hasher.HashSecret(change.Secret)
|
||||
if change.EventType == EventTypeDelete {
|
||||
hash = s.hasher.EmptyHash()
|
||||
}
|
||||
|
||||
return s.processResource(
|
||||
change.Secret.Name,
|
||||
change.Secret.Namespace,
|
||||
change.Secret.Annotations,
|
||||
ResourceTypeSecret,
|
||||
hash,
|
||||
workloads,
|
||||
)
|
||||
}
|
||||
|
||||
// processResource processes a resource change against all workloads.
|
||||
func (s *Service) processResource(
|
||||
resourceName string,
|
||||
resourceNamespace string,
|
||||
resourceAnnotations map[string]string,
|
||||
resourceType ResourceType,
|
||||
hash string,
|
||||
workloads []workload.WorkloadAccessor,
|
||||
) []ReloadDecision {
|
||||
var decisions []ReloadDecision
|
||||
|
||||
for _, wl := range workloads {
|
||||
// Skip workloads in different namespaces
|
||||
if wl.GetNamespace() != resourceNamespace {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if workload should be ignored based on type
|
||||
if s.cfg.IsWorkloadIgnored(string(wl.Kind())) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if workload uses this resource (via volumes or env)
|
||||
var usesResource bool
|
||||
switch resourceType {
|
||||
case ResourceTypeConfigMap:
|
||||
usesResource = wl.UsesConfigMap(resourceName)
|
||||
case ResourceTypeSecret:
|
||||
usesResource = wl.UsesSecret(resourceName)
|
||||
}
|
||||
|
||||
// Build match input
|
||||
input := MatchInput{
|
||||
ResourceName: resourceName,
|
||||
ResourceNamespace: resourceNamespace,
|
||||
ResourceType: resourceType,
|
||||
ResourceAnnotations: resourceAnnotations,
|
||||
WorkloadAnnotations: wl.GetAnnotations(),
|
||||
PodAnnotations: wl.GetPodTemplateAnnotations(),
|
||||
}
|
||||
|
||||
// Check if we should reload
|
||||
matchResult := s.matcher.ShouldReload(input)
|
||||
|
||||
// For auto-reload, the workload must actually use the resource
|
||||
// For explicit annotation, the user explicitly requested it
|
||||
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
|
||||
}
|
||||
|
||||
// shouldProcessEvent checks if the event type should be processed.
|
||||
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.
|
||||
// This modifies the workload in-place but does not persist the changes.
|
||||
// Returns true if changes were made, false otherwise.
|
||||
func (s *Service) ApplyReload(
|
||||
ctx context.Context,
|
||||
wl workload.WorkloadAccessor,
|
||||
resourceName string,
|
||||
resourceType ResourceType,
|
||||
namespace string,
|
||||
hash string,
|
||||
autoReload bool,
|
||||
) (bool, error) {
|
||||
// Find the target container
|
||||
container := s.findTargetContainer(wl, resourceName, resourceType, autoReload)
|
||||
|
||||
input := StrategyInput{
|
||||
ResourceName: resourceName,
|
||||
ResourceType: resourceType,
|
||||
Namespace: namespace,
|
||||
Hash: hash,
|
||||
Container: container,
|
||||
PodAnnotations: wl.GetPodTemplateAnnotations(),
|
||||
AutoReload: autoReload,
|
||||
}
|
||||
|
||||
return s.strategy.Apply(input)
|
||||
}
|
||||
|
||||
// findTargetContainer finds the container to target for the reload.
|
||||
// For auto-reload, it finds the container that uses the resource.
|
||||
// For explicit annotation, it returns the first container.
|
||||
func (s *Service) findTargetContainer(
|
||||
wl workload.WorkloadAccessor,
|
||||
resourceName string,
|
||||
resourceType ResourceType,
|
||||
autoReload bool,
|
||||
) *corev1.Container {
|
||||
containers := wl.GetContainers()
|
||||
if len(containers) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// For explicit annotation, return the first container
|
||||
if !autoReload {
|
||||
return &containers[0]
|
||||
}
|
||||
|
||||
volumes := wl.GetVolumes()
|
||||
initContainers := wl.GetInitContainers()
|
||||
|
||||
// For auto-reload, find the container that uses the resource
|
||||
// Check volumes first
|
||||
volumeName := s.findVolumeUsingResource(volumes, resourceName, resourceType)
|
||||
if volumeName != "" {
|
||||
container := s.findContainerWithVolumeMount(containers, volumeName)
|
||||
if container != nil {
|
||||
return container
|
||||
}
|
||||
// Check init containers
|
||||
container = s.findContainerWithVolumeMount(initContainers, volumeName)
|
||||
if container != nil {
|
||||
// Return the first regular container for init container refs
|
||||
return &containers[0]
|
||||
}
|
||||
}
|
||||
|
||||
// Check env references
|
||||
container := s.findContainerWithEnvRef(containers, resourceName, resourceType)
|
||||
if container != nil {
|
||||
return container
|
||||
}
|
||||
|
||||
// Check init container env references
|
||||
container = s.findContainerWithEnvRef(initContainers, resourceName, resourceType)
|
||||
if container != nil {
|
||||
// Return the first regular container for init container refs
|
||||
return &containers[0]
|
||||
}
|
||||
|
||||
// Default to first container
|
||||
return &containers[0]
|
||||
}
|
||||
|
||||
// findVolumeUsingResource finds a volume that uses the given resource.
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// findContainerWithVolumeMount finds a container that mounts the given volume.
|
||||
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
|
||||
}
|
||||
|
||||
// findContainerWithEnvRef finds a container that references the resource via env.
|
||||
func (s *Service) findContainerWithEnvRef(containers []corev1.Container, resourceName string, resourceType ResourceType) *corev1.Container {
|
||||
for i := range containers {
|
||||
// Check env vars
|
||||
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]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check envFrom
|
||||
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
|
||||
}
|
||||
|
||||
// Matcher returns the matcher used by this service.
|
||||
func (s *Service) Matcher() *Matcher {
|
||||
return s.matcher
|
||||
}
|
||||
|
||||
// Strategy returns the strategy used by this service.
|
||||
func (s *Service) Strategy() Strategy {
|
||||
return s.strategy
|
||||
}
|
||||
|
||||
// ListWorkloads lists all workloads in the given namespace.
|
||||
// If namespace is empty, lists workloads in all namespaces.
|
||||
func ListWorkloads(ctx context.Context, c client.Client, namespace string, registry *workload.Registry) ([]workload.WorkloadAccessor, error) {
|
||||
var workloads []workload.WorkloadAccessor
|
||||
|
||||
for _, kind := range registry.SupportedKinds() {
|
||||
list, err := listWorkloadsByKind(ctx, c, namespace, kind)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("listing %s: %w", kind, err)
|
||||
}
|
||||
workloads = append(workloads, list...)
|
||||
}
|
||||
|
||||
return workloads, nil
|
||||
}
|
||||
|
||||
// listWorkloadsByKind lists workloads of a specific kind.
|
||||
func listWorkloadsByKind(ctx context.Context, c client.Client, namespace string, kind workload.Kind) ([]workload.WorkloadAccessor, error) {
|
||||
// This will be implemented by the controller using the appropriate list functions
|
||||
// For now, return empty slice as the controller will handle this
|
||||
return nil, nil
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
package reload
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/stakater/Reloader/internal/pkg/config"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
)
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
// Strategy defines how workload restarts are triggered.
|
||||
type Strategy interface {
|
||||
// Apply applies the reload strategy to the pod spec.
|
||||
// Returns true if changes were made, false otherwise.
|
||||
Apply(input StrategyInput) (bool, error)
|
||||
|
||||
// Name returns the strategy name for logging purposes.
|
||||
Name() string
|
||||
}
|
||||
|
||||
// StrategyInput contains the information needed to apply a reload strategy.
|
||||
type StrategyInput struct {
|
||||
// ResourceName is the name of the ConfigMap or Secret that changed.
|
||||
ResourceName string
|
||||
// ResourceType is the type of resource (configmap or secret).
|
||||
ResourceType ResourceType
|
||||
// Namespace is the namespace of the resource.
|
||||
Namespace string
|
||||
// Hash is the SHA hash of the resource content.
|
||||
Hash string
|
||||
// Container is the container to target for env var injection.
|
||||
// If nil, the first container is used.
|
||||
Container *corev1.Container
|
||||
// PodAnnotations is the pod template annotations map (for annotation strategy).
|
||||
PodAnnotations map[string]string
|
||||
// AutoReload indicates if this is an auto-reload (affects container selection).
|
||||
AutoReload bool
|
||||
}
|
||||
|
||||
// ReloadSource contains metadata about what triggered a reload.
|
||||
// This is stored in the annotation when using annotation strategy.
|
||||
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.
|
||||
// This is the default strategy and is GitOps-friendly.
|
||||
type EnvVarStrategy struct{}
|
||||
|
||||
// NewEnvVarStrategy creates a new EnvVarStrategy.
|
||||
func NewEnvVarStrategy() *EnvVarStrategy {
|
||||
return &EnvVarStrategy{}
|
||||
}
|
||||
|
||||
// Name returns the strategy name.
|
||||
func (s *EnvVarStrategy) Name() string {
|
||||
return string(config.ReloadStrategyEnvVars)
|
||||
}
|
||||
|
||||
// Apply adds or updates 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)
|
||||
|
||||
// Check if env var already exists
|
||||
for i := range input.Container.Env {
|
||||
if input.Container.Env[i].Name == envVarName {
|
||||
if input.Container.Env[i].Value == input.Hash {
|
||||
// Already up to date
|
||||
return false, nil
|
||||
}
|
||||
// Update existing
|
||||
input.Container.Env[i].Value = input.Hash
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Add new env var
|
||||
input.Container.Env = append(input.Container.Env, corev1.EnvVar{
|
||||
Name: envVarName,
|
||||
Value: input.Hash,
|
||||
})
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// envVarName generates the environment variable name for a resource.
|
||||
func (s *EnvVarStrategy) envVarName(resourceName string, resourceType ResourceType) string {
|
||||
var postfix string
|
||||
switch resourceType {
|
||||
case ResourceTypeConfigMap:
|
||||
postfix = ConfigmapEnvVarPostfix
|
||||
case ResourceTypeSecret:
|
||||
postfix = SecretEnvVarPostfix
|
||||
}
|
||||
return EnvVarPrefix + convertToEnvVarName(resourceName) + "_" + postfix
|
||||
}
|
||||
|
||||
// convertToEnvVarName converts a string to a valid environment variable name.
|
||||
// Invalid characters are replaced with underscores, and the result is uppercased.
|
||||
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}
|
||||
}
|
||||
|
||||
// Name returns the strategy name.
|
||||
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
|
||||
}
|
||||
|
||||
// Create reload source metadata
|
||||
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]
|
||||
|
||||
if existingValue == string(sourceJSON) {
|
||||
// Already up to date
|
||||
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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user