From 94e3fcd702b635187e6dd56cd1a6919312819ae0 Mon Sep 17 00:00:00 2001 From: TheiLLeniumStudios <104288623+TheiLLeniumStudios@users.noreply.github.com> Date: Sun, 28 Dec 2025 08:47:51 +0100 Subject: [PATCH] feat: Add reload package with core matching and strategy logic --- internal/pkg/reload/hasher.go | 85 +++++++ internal/pkg/reload/matcher.go | 320 ++++++++++++++++++++++++ internal/pkg/reload/predicate.go | 146 +++++++++++ internal/pkg/reload/service.go | 401 +++++++++++++++++++++++++++++++ internal/pkg/reload/strategy.go | 203 ++++++++++++++++ 5 files changed, 1155 insertions(+) create mode 100644 internal/pkg/reload/hasher.go create mode 100644 internal/pkg/reload/matcher.go create mode 100644 internal/pkg/reload/predicate.go create mode 100644 internal/pkg/reload/service.go create mode 100644 internal/pkg/reload/strategy.go diff --git a/internal/pkg/reload/hasher.go b/internal/pkg/reload/hasher.go new file mode 100644 index 00000000..6c1b1613 --- /dev/null +++ b/internal/pkg/reload/hasher.go @@ -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("") +} diff --git a/internal/pkg/reload/matcher.go b/internal/pkg/reload/matcher.go new file mode 100644 index 00000000..51bd7d6c --- /dev/null +++ b/internal/pkg/reload/matcher.go @@ -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 "" + } +} diff --git a/internal/pkg/reload/predicate.go b/internal/pkg/reload/predicate.go new file mode 100644 index 00000000..7e6381e2 --- /dev/null +++ b/internal/pkg/reload/predicate.go @@ -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...) +} diff --git a/internal/pkg/reload/service.go b/internal/pkg/reload/service.go new file mode 100644 index 00000000..169ae9ea --- /dev/null +++ b/internal/pkg/reload/service.go @@ -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 +} diff --git a/internal/pkg/reload/strategy.go b/internal/pkg/reload/strategy.go new file mode 100644 index 00000000..ce938a7e --- /dev/null +++ b/internal/pkg/reload/strategy.go @@ -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() + } +}