diff --git a/internal/pkg/metadata/metadata.go b/internal/pkg/metadata/metadata.go new file mode 100644 index 00000000..09db4e8e --- /dev/null +++ b/internal/pkg/metadata/metadata.go @@ -0,0 +1,283 @@ +// Package metadata provides metadata ConfigMap creation for Reloader. +// The metadata ConfigMap contains build info, configuration options, and deployment info. +package metadata + +import ( + "context" + "encoding/json" + "fmt" + "os" + "runtime" + "time" + + "github.com/sirupsen/logrus" + "github.com/stakater/Reloader/internal/pkg/config" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +const ( + // ConfigMapName is the name of the metadata ConfigMap. + ConfigMapName = "reloader-meta-info" + // ConfigMapLabelKey is the label key for the metadata ConfigMap. + ConfigMapLabelKey = "reloader.stakater.com/meta-info" + // ConfigMapLabelValue is the label value for the metadata ConfigMap. + ConfigMapLabelValue = "reloader-oss" + // FieldManager is the field manager name for server-side apply. + FieldManager = "reloader" + + // Environment variables for deployment info. + EnvReloaderNamespace = "RELOADER_NAMESPACE" + EnvReloaderDeploymentName = "RELOADER_DEPLOYMENT_NAME" +) + +// Version, Commit, and BuildDate are set during the build process +// using the -X linker flag to inject these values into the binary. +var ( + Version = "dev" + Commit = "unknown" + BuildDate = "unknown" +) + +// MetaInfo contains comprehensive metadata about the Reloader instance. +type MetaInfo struct { + // BuildInfo contains information about the build version, commit, and compilation details. + BuildInfo BuildInfo `json:"buildInfo"` + // ReloaderOptions contains all the configuration options used by this Reloader instance. + ReloaderOptions ReloaderOptions `json:"reloaderOptions"` + // DeploymentInfo contains metadata about the Kubernetes deployment of this instance. + DeploymentInfo DeploymentInfo `json:"deploymentInfo"` +} + +// BuildInfo contains information about the build and version of the Reloader binary. +type BuildInfo struct { + // GoVersion is the version of Go used to compile the binary. + GoVersion string `json:"goVersion"` + // ReleaseVersion is the version tag or branch of the Reloader release. + ReleaseVersion string `json:"releaseVersion"` + // CommitHash is the Git commit hash of the source code used to build this binary. + CommitHash string `json:"commitHash"` + // CommitTime is the timestamp of the Git commit used to build this binary. + CommitTime time.Time `json:"commitTime"` +} + +// DeploymentInfo contains metadata about the Reloader deployment. +type DeploymentInfo struct { + // Name is the name of the Reloader deployment. + Name string `json:"name"` + // Namespace is the namespace where Reloader is deployed. + Namespace string `json:"namespace"` +} + +// ReloaderOptions contains the configuration options for Reloader. +// This is a subset of config.Config that's relevant for the metadata ConfigMap. +type ReloaderOptions struct { + // AutoReloadAll enables automatic reloading of all resources. + AutoReloadAll bool `json:"autoReloadAll"` + // ReloadStrategy specifies the strategy used to trigger resource reloads. + ReloadStrategy string `json:"reloadStrategy"` + // IsArgoRollouts indicates whether support for Argo Rollouts is enabled. + IsArgoRollouts bool `json:"isArgoRollouts"` + // ReloadOnCreate indicates whether to trigger reloads when resources are created. + ReloadOnCreate bool `json:"reloadOnCreate"` + // ReloadOnDelete indicates whether to trigger reloads when resources are deleted. + ReloadOnDelete bool `json:"reloadOnDelete"` + // SyncAfterRestart indicates whether to sync add events after Reloader restarts. + SyncAfterRestart bool `json:"syncAfterRestart"` + // EnableHA indicates whether High Availability mode is enabled. + EnableHA bool `json:"enableHA"` + // WebhookURL is the URL to send webhook notifications to. + WebhookURL string `json:"webhookUrl"` + // LogFormat specifies the log format to use. + LogFormat string `json:"logFormat"` + // LogLevel specifies the log level to use. + LogLevel string `json:"logLevel"` + // ResourcesToIgnore is a list of resource types to ignore. + ResourcesToIgnore []string `json:"resourcesToIgnore"` + // WorkloadTypesToIgnore is a list of workload types to ignore. + WorkloadTypesToIgnore []string `json:"workloadTypesToIgnore"` + // NamespacesToIgnore is a list of namespaces to ignore. + NamespacesToIgnore []string `json:"namespacesToIgnore"` + // NamespaceSelectors is a list of namespace label selectors. + NamespaceSelectors []string `json:"namespaceSelectors"` + // ResourceSelectors is a list of resource label selectors. + ResourceSelectors []string `json:"resourceSelectors"` + + // Annotations + ConfigmapUpdateOnChangeAnnotation string `json:"configmapUpdateOnChangeAnnotation"` + SecretUpdateOnChangeAnnotation string `json:"secretUpdateOnChangeAnnotation"` + ReloaderAutoAnnotation string `json:"reloaderAutoAnnotation"` + ConfigmapReloaderAutoAnnotation string `json:"configmapReloaderAutoAnnotation"` + SecretReloaderAutoAnnotation string `json:"secretReloaderAutoAnnotation"` + IgnoreResourceAnnotation string `json:"ignoreResourceAnnotation"` + ConfigmapExcludeReloaderAnnotation string `json:"configmapExcludeReloaderAnnotation"` + SecretExcludeReloaderAnnotation string `json:"secretExcludeReloaderAnnotation"` + AutoSearchAnnotation string `json:"autoSearchAnnotation"` + SearchMatchAnnotation string `json:"searchMatchAnnotation"` + RolloutStrategyAnnotation string `json:"rolloutStrategyAnnotation"` + PauseDeploymentAnnotation string `json:"pauseDeploymentAnnotation"` + PauseDeploymentTimeAnnotation string `json:"pauseDeploymentTimeAnnotation"` +} + +// NewBuildInfo creates a new BuildInfo with current build information. +func NewBuildInfo() BuildInfo { + return BuildInfo{ + GoVersion: runtime.Version(), + ReleaseVersion: Version, + CommitHash: Commit, + CommitTime: parseUTCTime(BuildDate), + } +} + +// NewReloaderOptions creates ReloaderOptions from a Config. +func NewReloaderOptions(cfg *config.Config) ReloaderOptions { + return ReloaderOptions{ + AutoReloadAll: cfg.AutoReloadAll, + ReloadStrategy: string(cfg.ReloadStrategy), + IsArgoRollouts: cfg.ArgoRolloutsEnabled, + ReloadOnCreate: cfg.ReloadOnCreate, + ReloadOnDelete: cfg.ReloadOnDelete, + SyncAfterRestart: cfg.SyncAfterRestart, + EnableHA: cfg.EnableHA, + WebhookURL: cfg.WebhookURL, + LogFormat: cfg.LogFormat, + LogLevel: cfg.LogLevel, + ResourcesToIgnore: cfg.IgnoredResources, + WorkloadTypesToIgnore: cfg.IgnoredWorkloads, + NamespacesToIgnore: cfg.IgnoredNamespaces, + NamespaceSelectors: cfg.NamespaceSelectorStrings, + ResourceSelectors: cfg.ResourceSelectorStrings, + ConfigmapUpdateOnChangeAnnotation: cfg.Annotations.ConfigmapReload, + SecretUpdateOnChangeAnnotation: cfg.Annotations.SecretReload, + ReloaderAutoAnnotation: cfg.Annotations.Auto, + ConfigmapReloaderAutoAnnotation: cfg.Annotations.ConfigmapAuto, + SecretReloaderAutoAnnotation: cfg.Annotations.SecretAuto, + IgnoreResourceAnnotation: cfg.Annotations.Ignore, + ConfigmapExcludeReloaderAnnotation: cfg.Annotations.ConfigmapExclude, + SecretExcludeReloaderAnnotation: cfg.Annotations.SecretExclude, + AutoSearchAnnotation: cfg.Annotations.Search, + SearchMatchAnnotation: cfg.Annotations.Match, + RolloutStrategyAnnotation: cfg.Annotations.RolloutStrategy, + PauseDeploymentAnnotation: cfg.Annotations.PausePeriod, + PauseDeploymentTimeAnnotation: cfg.Annotations.PausedAt, + } +} + +// NewMetaInfo creates a new MetaInfo from configuration. +func NewMetaInfo(cfg *config.Config) *MetaInfo { + return &MetaInfo{ + BuildInfo: NewBuildInfo(), + ReloaderOptions: NewReloaderOptions(cfg), + DeploymentInfo: DeploymentInfo{ + Name: os.Getenv(EnvReloaderDeploymentName), + Namespace: os.Getenv(EnvReloaderNamespace), + }, + } +} + +// ToConfigMap converts MetaInfo to a Kubernetes ConfigMap. +func (m *MetaInfo) ToConfigMap() *corev1.ConfigMap { + return &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: ConfigMapName, + Namespace: m.DeploymentInfo.Namespace, + Labels: map[string]string{ + ConfigMapLabelKey: ConfigMapLabelValue, + }, + }, + Data: map[string]string{ + "buildInfo": toJSON(m.BuildInfo), + "reloaderOptions": toJSON(m.ReloaderOptions), + "deploymentInfo": toJSON(m.DeploymentInfo), + }, + } +} + +// Publisher handles creating and updating the metadata ConfigMap. +type Publisher struct { + client client.Client + cfg *config.Config +} + +// NewPublisher creates a new Publisher. +func NewPublisher(c client.Client, cfg *config.Config) *Publisher { + return &Publisher{ + client: c, + cfg: cfg, + } +} + +// Publish creates or updates the metadata ConfigMap. +// Returns an error if the operation fails, or nil on success. +// If RELOADER_NAMESPACE is not set, this is a no-op. +func (p *Publisher) Publish(ctx context.Context) error { + namespace := os.Getenv(EnvReloaderNamespace) + if namespace == "" { + logrus.Warn("RELOADER_NAMESPACE is not set, skipping meta info configmap creation") + return nil + } + + metaInfo := NewMetaInfo(p.cfg) + configMap := metaInfo.ToConfigMap() + + // Try to get existing ConfigMap + existing := &corev1.ConfigMap{} + err := p.client.Get(ctx, client.ObjectKey{ + Name: ConfigMapName, + Namespace: namespace, + }, existing) + + if err != nil { + if !errors.IsNotFound(err) { + return fmt.Errorf("failed to get existing meta info configmap: %w", err) + } + // ConfigMap doesn't exist, create it + logrus.Info("Creating meta info configmap") + if err := p.client.Create(ctx, configMap, client.FieldOwner(FieldManager)); err != nil { + return fmt.Errorf("failed to create meta info configmap: %w", err) + } + logrus.Info("Meta info configmap created successfully") + return nil + } + + // ConfigMap exists, update it + logrus.Info("Meta info configmap already exists, updating it") + existing.Data = configMap.Data + existing.Labels = configMap.Labels + if err := p.client.Update(ctx, existing, client.FieldOwner(FieldManager)); err != nil { + return fmt.Errorf("failed to update meta info configmap: %w", err) + } + logrus.Info("Meta info configmap updated successfully") + return nil +} + +// PublishMetaInfoConfigMap is a convenience function that creates a Publisher and calls Publish. +// This provides a simple API similar to the v1 PublishMetaInfoConfigmap function. +func PublishMetaInfoConfigMap(ctx context.Context, c client.Client, cfg *config.Config) error { + publisher := NewPublisher(c, cfg) + return publisher.Publish(ctx) +} + +// toJSON marshals data to JSON string. Returns empty string on error. +func toJSON(data interface{}) string { + jsonData, err := json.Marshal(data) + if err != nil { + return "" + } + return string(jsonData) +} + +// parseUTCTime parses a time string in RFC3339 format. +// Returns zero time if value is empty or parsing fails. +func parseUTCTime(value string) time.Time { + if value == "" { + return time.Time{} + } + t, err := time.Parse(time.RFC3339, value) + if err != nil { + return time.Time{} + } + return t +} diff --git a/internal/pkg/metadata/metadata_test.go b/internal/pkg/metadata/metadata_test.go new file mode 100644 index 00000000..94fce183 --- /dev/null +++ b/internal/pkg/metadata/metadata_test.go @@ -0,0 +1,330 @@ +package metadata + +import ( + "context" + "encoding/json" + "os" + "testing" + + "github.com/stakater/Reloader/internal/pkg/config" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +func TestNewBuildInfo(t *testing.T) { + // Set build variables for testing + oldVersion := Version + oldCommit := Commit + oldBuildDate := BuildDate + defer func() { + Version = oldVersion + Commit = oldCommit + BuildDate = oldBuildDate + }() + + Version = "1.0.0" + Commit = "abc123" + BuildDate = "2024-01-01T12:00:00Z" + + info := NewBuildInfo() + + if info.ReleaseVersion != "1.0.0" { + t.Errorf("ReleaseVersion = %s, want 1.0.0", info.ReleaseVersion) + } + if info.CommitHash != "abc123" { + t.Errorf("CommitHash = %s, want abc123", info.CommitHash) + } + if info.GoVersion == "" { + t.Error("GoVersion should not be empty") + } + if info.CommitTime.IsZero() { + t.Error("CommitTime should not be zero") + } +} + +func TestNewReloaderOptions(t *testing.T) { + cfg := config.NewDefault() + cfg.AutoReloadAll = true + cfg.ReloadStrategy = config.ReloadStrategyAnnotations + cfg.ArgoRolloutsEnabled = true + cfg.ReloadOnCreate = true + cfg.ReloadOnDelete = true + cfg.EnableHA = true + cfg.WebhookURL = "https://example.com/webhook" + cfg.LogFormat = "json" + cfg.LogLevel = "debug" + cfg.IgnoredResources = []string{"configmaps"} + cfg.IgnoredWorkloads = []string{"jobs"} + cfg.IgnoredNamespaces = []string{"kube-system"} + + opts := NewReloaderOptions(cfg) + + if !opts.AutoReloadAll { + t.Error("AutoReloadAll should be true") + } + if opts.ReloadStrategy != "annotations" { + t.Errorf("ReloadStrategy = %s, want annotations", opts.ReloadStrategy) + } + if !opts.IsArgoRollouts { + t.Error("IsArgoRollouts should be true") + } + if !opts.ReloadOnCreate { + t.Error("ReloadOnCreate should be true") + } + if !opts.ReloadOnDelete { + t.Error("ReloadOnDelete should be true") + } + if !opts.EnableHA { + t.Error("EnableHA should be true") + } + if opts.WebhookURL != "https://example.com/webhook" { + t.Errorf("WebhookURL = %s, want https://example.com/webhook", opts.WebhookURL) + } + if opts.LogFormat != "json" { + t.Errorf("LogFormat = %s, want json", opts.LogFormat) + } + if opts.LogLevel != "debug" { + t.Errorf("LogLevel = %s, want debug", opts.LogLevel) + } + if len(opts.ResourcesToIgnore) != 1 || opts.ResourcesToIgnore[0] != "configmaps" { + t.Errorf("ResourcesToIgnore = %v, want [configmaps]", opts.ResourcesToIgnore) + } + if len(opts.WorkloadTypesToIgnore) != 1 || opts.WorkloadTypesToIgnore[0] != "jobs" { + t.Errorf("WorkloadTypesToIgnore = %v, want [jobs]", opts.WorkloadTypesToIgnore) + } + if len(opts.NamespacesToIgnore) != 1 || opts.NamespacesToIgnore[0] != "kube-system" { + t.Errorf("NamespacesToIgnore = %v, want [kube-system]", opts.NamespacesToIgnore) + } + + // Check annotations + if opts.ReloaderAutoAnnotation != "reloader.stakater.com/auto" { + t.Errorf("ReloaderAutoAnnotation = %s, want reloader.stakater.com/auto", opts.ReloaderAutoAnnotation) + } +} + +func TestMetaInfo_ToConfigMap(t *testing.T) { + // Set environment variables + os.Setenv(EnvReloaderNamespace, "reloader-ns") + os.Setenv(EnvReloaderDeploymentName, "reloader-deploy") + defer func() { + os.Unsetenv(EnvReloaderNamespace) + os.Unsetenv(EnvReloaderDeploymentName) + }() + + cfg := config.NewDefault() + metaInfo := NewMetaInfo(cfg) + cm := metaInfo.ToConfigMap() + + if cm.Name != ConfigMapName { + t.Errorf("Name = %s, want %s", cm.Name, ConfigMapName) + } + if cm.Namespace != "reloader-ns" { + t.Errorf("Namespace = %s, want reloader-ns", cm.Namespace) + } + if cm.Labels[ConfigMapLabelKey] != ConfigMapLabelValue { + t.Errorf("Label = %s, want %s", cm.Labels[ConfigMapLabelKey], ConfigMapLabelValue) + } + + // Check data fields exist + if _, ok := cm.Data["buildInfo"]; !ok { + t.Error("buildInfo data key missing") + } + if _, ok := cm.Data["reloaderOptions"]; !ok { + t.Error("reloaderOptions data key missing") + } + if _, ok := cm.Data["deploymentInfo"]; !ok { + t.Error("deploymentInfo data key missing") + } + + // Verify buildInfo is valid JSON + var buildInfo BuildInfo + if err := json.Unmarshal([]byte(cm.Data["buildInfo"]), &buildInfo); err != nil { + t.Errorf("buildInfo is not valid JSON: %v", err) + } + + // Verify deploymentInfo contains expected values + var deployInfo DeploymentInfo + if err := json.Unmarshal([]byte(cm.Data["deploymentInfo"]), &deployInfo); err != nil { + t.Errorf("deploymentInfo is not valid JSON: %v", err) + } + if deployInfo.Namespace != "reloader-ns" { + t.Errorf("DeploymentInfo.Namespace = %s, want reloader-ns", deployInfo.Namespace) + } + if deployInfo.Name != "reloader-deploy" { + t.Errorf("DeploymentInfo.Name = %s, want reloader-deploy", deployInfo.Name) + } +} + +func TestPublisher_Publish_NoNamespace(t *testing.T) { + // Ensure RELOADER_NAMESPACE is not set + os.Unsetenv(EnvReloaderNamespace) + + scheme := runtime.NewScheme() + _ = corev1.AddToScheme(scheme) + fakeClient := fake.NewClientBuilder().WithScheme(scheme).Build() + + cfg := config.NewDefault() + publisher := NewPublisher(fakeClient, cfg) + + err := publisher.Publish(context.Background()) + if err != nil { + t.Errorf("Publish() with no namespace should not error, got: %v", err) + } +} + +func TestPublisher_Publish_CreateNew(t *testing.T) { + // Set environment variables + os.Setenv(EnvReloaderNamespace, "test-ns") + os.Setenv(EnvReloaderDeploymentName, "test-deploy") + defer func() { + os.Unsetenv(EnvReloaderNamespace) + os.Unsetenv(EnvReloaderDeploymentName) + }() + + scheme := runtime.NewScheme() + _ = corev1.AddToScheme(scheme) + fakeClient := fake.NewClientBuilder().WithScheme(scheme).Build() + + cfg := config.NewDefault() + publisher := NewPublisher(fakeClient, cfg) + + ctx := context.Background() + err := publisher.Publish(ctx) + if err != nil { + t.Errorf("Publish() error = %v", err) + } + + // Verify ConfigMap was created + cm := &corev1.ConfigMap{} + err = fakeClient.Get(ctx, client.ObjectKey{Name: ConfigMapName, Namespace: "test-ns"}, cm) + if err != nil { + t.Errorf("Failed to get created ConfigMap: %v", err) + } + if cm.Name != ConfigMapName { + t.Errorf("ConfigMap.Name = %s, want %s", cm.Name, ConfigMapName) + } +} + +func TestPublisher_Publish_UpdateExisting(t *testing.T) { + // Set environment variables + os.Setenv(EnvReloaderNamespace, "test-ns") + os.Setenv(EnvReloaderDeploymentName, "test-deploy") + defer func() { + os.Unsetenv(EnvReloaderNamespace) + os.Unsetenv(EnvReloaderDeploymentName) + }() + + scheme := runtime.NewScheme() + _ = corev1.AddToScheme(scheme) + + // Create existing ConfigMap with old data + existingCM := &corev1.ConfigMap{} + existingCM.Name = ConfigMapName + existingCM.Namespace = "test-ns" + existingCM.Data = map[string]string{ + "buildInfo": `{"goVersion":"old"}`, + } + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(existingCM). + Build() + + cfg := config.NewDefault() + publisher := NewPublisher(fakeClient, cfg) + + ctx := context.Background() + err := publisher.Publish(ctx) + if err != nil { + t.Errorf("Publish() error = %v", err) + } + + // Verify ConfigMap was updated + cm := &corev1.ConfigMap{} + err = fakeClient.Get(ctx, client.ObjectKey{Name: ConfigMapName, Namespace: "test-ns"}, cm) + if err != nil { + t.Errorf("Failed to get updated ConfigMap: %v", err) + } + + // Check that all data keys are present + if _, ok := cm.Data["buildInfo"]; !ok { + t.Error("buildInfo data key missing after update") + } + if _, ok := cm.Data["reloaderOptions"]; !ok { + t.Error("reloaderOptions data key missing after update") + } + if _, ok := cm.Data["deploymentInfo"]; !ok { + t.Error("deploymentInfo data key missing after update") + } + + // Verify labels were added + if cm.Labels[ConfigMapLabelKey] != ConfigMapLabelValue { + t.Errorf("Label not updated: %s", cm.Labels[ConfigMapLabelKey]) + } +} + +func TestPublishMetaInfoConfigMap(t *testing.T) { + // Set environment variables + os.Setenv(EnvReloaderNamespace, "test-ns") + defer os.Unsetenv(EnvReloaderNamespace) + + scheme := runtime.NewScheme() + _ = corev1.AddToScheme(scheme) + fakeClient := fake.NewClientBuilder().WithScheme(scheme).Build() + + cfg := config.NewDefault() + ctx := context.Background() + + err := PublishMetaInfoConfigMap(ctx, fakeClient, cfg) + if err != nil { + t.Errorf("PublishMetaInfoConfigMap() error = %v", err) + } + + // Verify ConfigMap was created + cm := &corev1.ConfigMap{} + err = fakeClient.Get(ctx, client.ObjectKey{Name: ConfigMapName, Namespace: "test-ns"}, cm) + if err != nil { + t.Errorf("Failed to get created ConfigMap: %v", err) + } +} + +func TestParseUTCTime(t *testing.T) { + tests := []struct { + name string + input string + wantErr bool + }{ + { + name: "valid RFC3339 time", + input: "2024-01-01T12:00:00Z", + wantErr: false, + }, + { + name: "empty string", + input: "", + wantErr: true, // returns zero time + }, + { + name: "invalid format", + input: "not-a-time", + wantErr: true, // returns zero time + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := parseUTCTime(tt.input) + if tt.wantErr { + if !result.IsZero() { + t.Errorf("parseUTCTime(%s) should return zero time", tt.input) + } + } else { + if result.IsZero() { + t.Errorf("parseUTCTime(%s) should not return zero time", tt.input) + } + } + }) + } +} diff --git a/internal/pkg/reload/hasher.go b/internal/pkg/reload/hasher.go index 6c1b1613..3839fd1a 100644 --- a/internal/pkg/reload/hasher.go +++ b/internal/pkg/reload/hasher.go @@ -78,8 +78,8 @@ func (h *Hasher) computeSHA(data string) string { return fmt.Sprintf("%x", hasher.Sum(nil)) } -// EmptyHash returns the hash of empty content. -// This is useful for comparison when resources are deleted. +// EmptyHash returns an empty string to signal resource deletion. +// This triggers env var removal when using the env-vars strategy. func (h *Hasher) EmptyHash() string { - return h.computeSHA("") + return "" } diff --git a/internal/pkg/reload/hasher_test.go b/internal/pkg/reload/hasher_test.go index 0b892adc..92edbf34 100644 --- a/internal/pkg/reload/hasher_test.go +++ b/internal/pkg/reload/hasher_test.go @@ -20,7 +20,8 @@ func TestHasher_HashConfigMap(t *testing.T) { Data: nil, BinaryData: nil, }, - wantHash: hasher.EmptyHash(), + // Empty configmap gets a valid hash (hash of empty data) + wantHash: hasher.HashConfigMap(&corev1.ConfigMap{}), }, { name: "configmap with data", @@ -120,7 +121,8 @@ func TestHasher_HashSecret(t *testing.T) { secret: &corev1.Secret{ Data: nil, }, - wantHash: hasher.EmptyHash(), + // Empty secret gets a valid hash (hash of empty data) + wantHash: hasher.HashSecret(&corev1.Secret{}), }, { name: "secret with data", @@ -196,36 +198,39 @@ func TestHasher_HashSecret_DifferentValues(t *testing.T) { func TestHasher_EmptyHash(t *testing.T) { hasher := NewHasher() + // EmptyHash returns empty string to signal deletion emptyHash := hasher.EmptyHash() - if emptyHash == "" { - t.Error("EmptyHash should not be empty string") + if emptyHash != "" { + t.Errorf("EmptyHash should be empty string, got %s", emptyHash) } - // Empty ConfigMap should match EmptyHash + // Empty ConfigMap should have a valid hash (not empty) cm := &corev1.ConfigMap{} - if hasher.HashConfigMap(cm) != emptyHash { - t.Error("Empty ConfigMap hash should equal EmptyHash") + cmHash := hasher.HashConfigMap(cm) + if cmHash == "" { + t.Error("Empty ConfigMap should have a non-empty hash") } - // Empty Secret should match EmptyHash + // Empty Secret should have a valid hash (not empty) secret := &corev1.Secret{} - if hasher.HashSecret(secret) != emptyHash { - t.Error("Empty Secret hash should equal EmptyHash") + secretHash := hasher.HashSecret(secret) + if secretHash == "" { + t.Error("Empty Secret should have a non-empty hash") } } func TestHasher_NilInput(t *testing.T) { hasher := NewHasher() - // Test nil ConfigMap + // Test nil ConfigMap - returns hash of empty content (not EmptyHash) cmHash := hasher.HashConfigMap(nil) - if cmHash != hasher.EmptyHash() { - t.Errorf("nil ConfigMap should return EmptyHash, got %s", cmHash) + if cmHash == "" { + t.Error("nil ConfigMap should return a valid hash") } - // Test nil Secret + // Test nil Secret - returns hash of empty content (not EmptyHash) secretHash := hasher.HashSecret(nil) - if secretHash != hasher.EmptyHash() { - t.Errorf("nil Secret should return EmptyHash, got %s", secretHash) + if secretHash == "" { + t.Error("nil Secret should return a valid hash") } } diff --git a/internal/pkg/reload/predicate_test.go b/internal/pkg/reload/predicate_test.go new file mode 100644 index 00000000..5386121e --- /dev/null +++ b/internal/pkg/reload/predicate_test.go @@ -0,0 +1,502 @@ +package reload + +import ( + "testing" + + "github.com/stakater/Reloader/internal/pkg/config" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/labels" + "sigs.k8s.io/controller-runtime/pkg/event" +) + +func TestNamespaceFilterPredicate_Create(t *testing.T) { + tests := []struct { + name string + ignoredNamespaces []string + eventNamespace string + wantAllow bool + }{ + { + name: "allow non-ignored namespace", + ignoredNamespaces: []string{"kube-system"}, + eventNamespace: "default", + wantAllow: true, + }, + { + name: "block ignored namespace", + ignoredNamespaces: []string{"kube-system"}, + eventNamespace: "kube-system", + wantAllow: false, + }, + { + name: "allow when no namespaces ignored", + ignoredNamespaces: []string{}, + eventNamespace: "kube-system", + wantAllow: true, + }, + { + name: "block multiple ignored namespaces", + ignoredNamespaces: []string{"kube-system", "kube-public", "test-ns"}, + eventNamespace: "test-ns", + wantAllow: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := config.NewDefault() + cfg.IgnoredNamespaces = tt.ignoredNamespaces + predicate := NamespaceFilterPredicate(cfg) + + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-cm", + Namespace: tt.eventNamespace, + }, + } + + e := event.CreateEvent{Object: cm} + got := predicate.Create(e) + + if got != tt.wantAllow { + t.Errorf("Create() = %v, want %v", got, tt.wantAllow) + } + }) + } +} + +func TestNamespaceFilterPredicate_Update(t *testing.T) { + cfg := config.NewDefault() + cfg.IgnoredNamespaces = []string{"kube-system"} + predicate := NamespaceFilterPredicate(cfg) + + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-cm", + Namespace: "default", + }, + } + + e := event.UpdateEvent{ObjectNew: cm} + if !predicate.Update(e) { + t.Error("Update() should allow non-ignored namespace") + } + + cm.Namespace = "kube-system" + e = event.UpdateEvent{ObjectNew: cm} + if predicate.Update(e) { + t.Error("Update() should block ignored namespace") + } +} + +func TestNamespaceFilterPredicate_Delete(t *testing.T) { + cfg := config.NewDefault() + cfg.IgnoredNamespaces = []string{"kube-system"} + predicate := NamespaceFilterPredicate(cfg) + + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-cm", + Namespace: "default", + }, + } + + e := event.DeleteEvent{Object: cm} + if !predicate.Delete(e) { + t.Error("Delete() should allow non-ignored namespace") + } +} + +func TestNamespaceFilterPredicate_Generic(t *testing.T) { + cfg := config.NewDefault() + cfg.IgnoredNamespaces = []string{"kube-system"} + predicate := NamespaceFilterPredicate(cfg) + + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-cm", + Namespace: "default", + }, + } + + e := event.GenericEvent{Object: cm} + if !predicate.Generic(e) { + t.Error("Generic() should allow non-ignored namespace") + } +} + +func TestLabelSelectorPredicate_Create(t *testing.T) { + tests := []struct { + name string + selector string + objectLabels map[string]string + wantAllow bool + }{ + { + name: "match single label", + selector: "app=reloader", + objectLabels: map[string]string{"app": "reloader"}, + wantAllow: true, + }, + { + name: "no match single label", + selector: "app=reloader", + objectLabels: map[string]string{"app": "other"}, + wantAllow: false, + }, + { + name: "match multiple labels", + selector: "app=reloader,env=prod", + objectLabels: map[string]string{"app": "reloader", "env": "prod", "extra": "value"}, + wantAllow: true, + }, + { + name: "partial match fails", + selector: "app=reloader,env=prod", + objectLabels: map[string]string{"app": "reloader"}, + wantAllow: false, + }, + { + name: "empty labels no match", + selector: "app=reloader", + objectLabels: map[string]string{}, + wantAllow: false, + }, + { + name: "nil labels no match", + selector: "app=reloader", + objectLabels: nil, + wantAllow: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := config.NewDefault() + selector, err := labels.Parse(tt.selector) + if err != nil { + t.Fatalf("Failed to parse selector: %v", err) + } + cfg.ResourceSelectors = []labels.Selector{selector} + predicate := LabelSelectorPredicate(cfg) + + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-cm", + Namespace: "default", + Labels: tt.objectLabels, + }, + } + + e := event.CreateEvent{Object: cm} + got := predicate.Create(e) + + if got != tt.wantAllow { + t.Errorf("Create() = %v, want %v", got, tt.wantAllow) + } + }) + } +} + +func TestLabelSelectorPredicate_NoSelectors(t *testing.T) { + cfg := config.NewDefault() + // No selectors configured + predicate := LabelSelectorPredicate(cfg) + + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-cm", + Namespace: "default", + Labels: map[string]string{"any": "label"}, + }, + } + + e := event.CreateEvent{Object: cm} + if !predicate.Create(e) { + t.Error("Create() should allow all when no selectors configured") + } +} + +func TestLabelSelectorPredicate_MultipleSelectors(t *testing.T) { + cfg := config.NewDefault() + selector1, _ := labels.Parse("app=reloader") + selector2, _ := labels.Parse("type=config") + cfg.ResourceSelectors = []labels.Selector{selector1, selector2} + predicate := LabelSelectorPredicate(cfg) + + tests := []struct { + name string + labels map[string]string + wantAllow bool + }{ + { + name: "matches first selector", + labels: map[string]string{"app": "reloader"}, + wantAllow: true, + }, + { + name: "matches second selector", + labels: map[string]string{"type": "config"}, + wantAllow: true, + }, + { + name: "matches both selectors", + labels: map[string]string{"app": "reloader", "type": "config"}, + wantAllow: true, + }, + { + name: "matches neither selector", + labels: map[string]string{"other": "value"}, + wantAllow: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-cm", + Namespace: "default", + Labels: tt.labels, + }, + } + + e := event.CreateEvent{Object: cm} + got := predicate.Create(e) + + if got != tt.wantAllow { + t.Errorf("Create() = %v, want %v", got, tt.wantAllow) + } + }) + } +} + +func TestLabelSelectorPredicate_Update(t *testing.T) { + cfg := config.NewDefault() + selector, _ := labels.Parse("app=reloader") + cfg.ResourceSelectors = []labels.Selector{selector} + predicate := LabelSelectorPredicate(cfg) + + cmMatching := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-cm", + Namespace: "default", + Labels: map[string]string{"app": "reloader"}, + }, + } + + cmNotMatching := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-cm", + Namespace: "default", + Labels: map[string]string{"app": "other"}, + }, + } + + e := event.UpdateEvent{ObjectNew: cmMatching} + if !predicate.Update(e) { + t.Error("Update() should allow matching labels") + } + + e = event.UpdateEvent{ObjectNew: cmNotMatching} + if predicate.Update(e) { + t.Error("Update() should block non-matching labels") + } +} + +func TestLabelSelectorPredicate_Delete(t *testing.T) { + cfg := config.NewDefault() + selector, _ := labels.Parse("app=reloader") + cfg.ResourceSelectors = []labels.Selector{selector} + predicate := LabelSelectorPredicate(cfg) + + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-cm", + Namespace: "default", + Labels: map[string]string{"app": "reloader"}, + }, + } + + e := event.DeleteEvent{Object: cm} + if !predicate.Delete(e) { + t.Error("Delete() should allow matching labels") + } +} + +func TestLabelSelectorPredicate_Generic(t *testing.T) { + cfg := config.NewDefault() + selector, _ := labels.Parse("app=reloader") + cfg.ResourceSelectors = []labels.Selector{selector} + predicate := LabelSelectorPredicate(cfg) + + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-cm", + Namespace: "default", + Labels: map[string]string{"app": "reloader"}, + }, + } + + e := event.GenericEvent{Object: cm} + if !predicate.Generic(e) { + t.Error("Generic() should allow matching labels") + } +} + +func TestCombinedFiltering(t *testing.T) { + cfg := config.NewDefault() + cfg.IgnoredNamespaces = []string{"kube-system"} + selector, _ := labels.Parse("managed=true") + cfg.ResourceSelectors = []labels.Selector{selector} + + nsPredicate := NamespaceFilterPredicate(cfg) + labelPredicate := LabelSelectorPredicate(cfg) + + tests := []struct { + name string + namespace string + labels map[string]string + wantNSAllow bool + wantLabelAllow bool + }{ + { + name: "allowed namespace and matching labels", + namespace: "default", + labels: map[string]string{"managed": "true"}, + wantNSAllow: true, + wantLabelAllow: true, + }, + { + name: "allowed namespace but non-matching labels", + namespace: "default", + labels: map[string]string{"managed": "false"}, + wantNSAllow: true, + wantLabelAllow: false, + }, + { + name: "ignored namespace with matching labels", + namespace: "kube-system", + labels: map[string]string{"managed": "true"}, + wantNSAllow: false, + wantLabelAllow: true, + }, + { + name: "ignored namespace and non-matching labels", + namespace: "kube-system", + labels: map[string]string{"managed": "false"}, + wantNSAllow: false, + wantLabelAllow: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-cm", + Namespace: tt.namespace, + Labels: tt.labels, + }, + } + + e := event.CreateEvent{Object: cm} + + gotNS := nsPredicate.Create(e) + if gotNS != tt.wantNSAllow { + t.Errorf("Namespace predicate Create() = %v, want %v", gotNS, tt.wantNSAllow) + } + + gotLabel := labelPredicate.Create(e) + if gotLabel != tt.wantLabelAllow { + t.Errorf("Label predicate Create() = %v, want %v", gotLabel, tt.wantLabelAllow) + } + + // Both must be true for the event to pass through + combinedAllow := gotNS && gotLabel + expectedCombined := tt.wantNSAllow && tt.wantLabelAllow + if combinedAllow != expectedCombined { + t.Errorf("Combined allow = %v, want %v", combinedAllow, expectedCombined) + } + }) + } +} + +func TestFilteringWithSecrets(t *testing.T) { + cfg := config.NewDefault() + cfg.IgnoredNamespaces = []string{"kube-system"} + nsPredicate := NamespaceFilterPredicate(cfg) + + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-secret", + Namespace: "default", + }, + } + + e := event.CreateEvent{Object: secret} + if !nsPredicate.Create(e) { + t.Error("Should allow secret in non-ignored namespace") + } + + secret.Namespace = "kube-system" + e = event.CreateEvent{Object: secret} + if nsPredicate.Create(e) { + t.Error("Should block secret in ignored namespace") + } +} + +func TestExistsLabelSelector(t *testing.T) { + cfg := config.NewDefault() + // Selector that checks if label exists (any value) + selector, _ := labels.Parse("managed") + cfg.ResourceSelectors = []labels.Selector{selector} + predicate := LabelSelectorPredicate(cfg) + + tests := []struct { + name string + labels map[string]string + wantAllow bool + }{ + { + name: "label exists with value true", + labels: map[string]string{"managed": "true"}, + wantAllow: true, + }, + { + name: "label exists with value false", + labels: map[string]string{"managed": "false"}, + wantAllow: true, + }, + { + name: "label exists with empty value", + labels: map[string]string{"managed": ""}, + wantAllow: true, + }, + { + name: "label does not exist", + labels: map[string]string{"other": "value"}, + wantAllow: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-cm", + Namespace: "default", + Labels: tt.labels, + }, + } + + e := event.CreateEvent{Object: cm} + got := predicate.Create(e) + + if got != tt.wantAllow { + t.Errorf("Create() = %v, want %v", got, tt.wantAllow) + } + }) + } +} diff --git a/internal/pkg/reload/service.go b/internal/pkg/reload/service.go index 169ae9ea..d0f681d0 100644 --- a/internal/pkg/reload/service.go +++ b/internal/pkg/reload/service.go @@ -2,7 +2,9 @@ package reload import ( "context" + "encoding/json" "fmt" + "time" "github.com/stakater/Reloader/internal/pkg/config" "github.com/stakater/Reloader/internal/pkg/workload" @@ -224,7 +226,51 @@ func (s *Service) ApplyReload( AutoReload: autoReload, } - return s.strategy.Apply(input) + // Apply the strategy-specific changes + updated, err := s.strategy.Apply(input) + if err != nil { + return false, err + } + + // Always set the attribution annotation regardless of strategy + if updated { + s.setAttributionAnnotation(wl, resourceName, resourceType, namespace, hash, container) + } + + return updated, nil +} + +// setAttributionAnnotation sets the last-reloaded-from annotation on the pod template. +// This is always set regardless of the reload strategy for audit purposes. +func (s *Service) setAttributionAnnotation( + wl workload.WorkloadAccessor, + resourceName string, + resourceType ResourceType, + namespace string, + hash string, + container *corev1.Container, +) { + containerName := "" + if container != nil { + containerName = container.Name + } + + source := ReloadSource{ + Kind: string(resourceType), + Name: resourceName, + Namespace: namespace, + Hash: hash, + Containers: []string{containerName}, + ReloadedAt: time.Now().UTC(), + } + + sourceJSON, err := json.Marshal(source) + if err != nil { + // Non-fatal: skip annotation if marshaling fails + return + } + + wl.SetPodTemplateAnnotation(s.cfg.Annotations.LastReloadedFrom, string(sourceJSON)) } // findTargetContainer finds the container to target for the reload. diff --git a/internal/pkg/reload/service_test.go b/internal/pkg/reload/service_test.go new file mode 100644 index 00000000..06880424 --- /dev/null +++ b/internal/pkg/reload/service_test.go @@ -0,0 +1,620 @@ +package reload + +import ( + "context" + "testing" + + "github.com/stakater/Reloader/internal/pkg/config" + "github.com/stakater/Reloader/internal/pkg/workload" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestService_ProcessConfigMap_AutoReload(t *testing.T) { + cfg := config.NewDefault() + svc := NewService(cfg) + + // Create a deployment with auto annotation that uses the configmap + deploy := createTestDeployment("test-deploy", "default", map[string]string{ + "reloader.stakater.com/auto": "true", + }) + deploy.Spec.Template.Spec.Volumes = []corev1.Volume{ + { + Name: "config-vol", + VolumeSource: corev1.VolumeSource{ + ConfigMap: &corev1.ConfigMapVolumeSource{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: "test-cm", + }, + }, + }, + }, + } + + workloads := []workload.WorkloadAccessor{ + workload.NewDeploymentWorkload(deploy), + } + + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-cm", + Namespace: "default", + }, + Data: map[string]string{ + "key": "value", + }, + } + + change := ConfigMapChange{ + ConfigMap: cm, + EventType: EventTypeUpdate, + } + + decisions := svc.ProcessConfigMap(change, workloads) + + if len(decisions) != 1 { + t.Fatalf("Expected 1 decision, got %d", len(decisions)) + } + + if !decisions[0].ShouldReload { + t.Error("Expected ShouldReload to be true") + } + + if !decisions[0].AutoReload { + t.Error("Expected AutoReload to be true") + } + + if decisions[0].Hash == "" { + t.Error("Expected Hash to be non-empty") + } +} + +func TestService_ProcessConfigMap_ExplicitAnnotation(t *testing.T) { + cfg := config.NewDefault() + svc := NewService(cfg) + + // Create a deployment with explicit configmap annotation + deploy := createTestDeployment("test-deploy", "default", map[string]string{ + "configmap.reloader.stakater.com/reload": "test-cm", + }) + + workloads := []workload.WorkloadAccessor{ + workload.NewDeploymentWorkload(deploy), + } + + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-cm", + Namespace: "default", + }, + Data: map[string]string{ + "key": "value", + }, + } + + change := ConfigMapChange{ + ConfigMap: cm, + EventType: EventTypeUpdate, + } + + decisions := svc.ProcessConfigMap(change, workloads) + + if len(decisions) != 1 { + t.Fatalf("Expected 1 decision, got %d", len(decisions)) + } + + if !decisions[0].ShouldReload { + t.Error("Expected ShouldReload to be true for explicit annotation") + } + + if decisions[0].AutoReload { + t.Error("Expected AutoReload to be false for explicit annotation") + } +} + +func TestService_ProcessConfigMap_IgnoredResource(t *testing.T) { + cfg := config.NewDefault() + svc := NewService(cfg) + + // Create a deployment with auto annotation + deploy := createTestDeployment("test-deploy", "default", map[string]string{ + "reloader.stakater.com/auto": "true", + }) + deploy.Spec.Template.Spec.Volumes = []corev1.Volume{ + { + Name: "config-vol", + VolumeSource: corev1.VolumeSource{ + ConfigMap: &corev1.ConfigMapVolumeSource{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: "test-cm", + }, + }, + }, + }, + } + + workloads := []workload.WorkloadAccessor{ + workload.NewDeploymentWorkload(deploy), + } + + // ConfigMap with ignore annotation + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-cm", + Namespace: "default", + Annotations: map[string]string{ + "reloader.stakater.com/ignore": "true", + }, + }, + Data: map[string]string{ + "key": "value", + }, + } + + change := ConfigMapChange{ + ConfigMap: cm, + EventType: EventTypeUpdate, + } + + decisions := svc.ProcessConfigMap(change, workloads) + + // Should still get a decision, but ShouldReload should be false + for _, d := range decisions { + if d.ShouldReload { + t.Error("Expected ShouldReload to be false for ignored resource") + } + } +} + +func TestService_ProcessSecret_AutoReload(t *testing.T) { + cfg := config.NewDefault() + svc := NewService(cfg) + + // Create a deployment with auto annotation that uses the secret + deploy := createTestDeployment("test-deploy", "default", map[string]string{ + "reloader.stakater.com/auto": "true", + }) + deploy.Spec.Template.Spec.Volumes = []corev1.Volume{ + { + Name: "secret-vol", + VolumeSource: corev1.VolumeSource{ + Secret: &corev1.SecretVolumeSource{ + SecretName: "test-secret", + }, + }, + }, + } + + workloads := []workload.WorkloadAccessor{ + workload.NewDeploymentWorkload(deploy), + } + + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-secret", + Namespace: "default", + }, + Data: map[string][]byte{ + "key": []byte("value"), + }, + } + + change := SecretChange{ + Secret: secret, + EventType: EventTypeUpdate, + } + + decisions := svc.ProcessSecret(change, workloads) + + if len(decisions) != 1 { + t.Fatalf("Expected 1 decision, got %d", len(decisions)) + } + + if !decisions[0].ShouldReload { + t.Error("Expected ShouldReload to be true") + } + + if !decisions[0].AutoReload { + t.Error("Expected AutoReload to be true") + } +} + +func TestService_ProcessConfigMap_DeleteEvent(t *testing.T) { + cfg := config.NewDefault() + cfg.ReloadOnDelete = true + svc := NewService(cfg) + + // Create a deployment with explicit configmap annotation + deploy := createTestDeployment("test-deploy", "default", map[string]string{ + "configmap.reloader.stakater.com/reload": "test-cm", + }) + + workloads := []workload.WorkloadAccessor{ + workload.NewDeploymentWorkload(deploy), + } + + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-cm", + Namespace: "default", + }, + } + + change := ConfigMapChange{ + ConfigMap: cm, + EventType: EventTypeDelete, + } + + decisions := svc.ProcessConfigMap(change, workloads) + + if len(decisions) != 1 { + t.Fatalf("Expected 1 decision, got %d", len(decisions)) + } + + if !decisions[0].ShouldReload { + t.Error("Expected ShouldReload to be true for delete event") + } + + // Hash should be empty for delete events + if decisions[0].Hash != "" { + t.Errorf("Expected empty hash for delete event, got %s", decisions[0].Hash) + } +} + +func TestService_ProcessConfigMap_DeleteEventDisabled(t *testing.T) { + cfg := config.NewDefault() + cfg.ReloadOnDelete = false // Disabled by default + svc := NewService(cfg) + + deploy := createTestDeployment("test-deploy", "default", map[string]string{ + "configmap.reloader.stakater.com/reload": "test-cm", + }) + + workloads := []workload.WorkloadAccessor{ + workload.NewDeploymentWorkload(deploy), + } + + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-cm", + Namespace: "default", + }, + } + + change := ConfigMapChange{ + ConfigMap: cm, + EventType: EventTypeDelete, + } + + decisions := svc.ProcessConfigMap(change, workloads) + + // Should return nil when delete events are disabled + if decisions != nil { + t.Error("Expected nil decisions when delete events are disabled") + } +} + +func TestService_ApplyReload_EnvVarStrategy(t *testing.T) { + cfg := config.NewDefault() + cfg.ReloadStrategy = config.ReloadStrategyEnvVars + svc := NewService(cfg) + + deploy := createTestDeployment("test-deploy", "default", nil) + accessor := workload.NewDeploymentWorkload(deploy) + + ctx := context.Background() + updated, err := svc.ApplyReload(ctx, accessor, "test-cm", ResourceTypeConfigMap, "default", "abc123hash", false) + + if err != nil { + t.Fatalf("ApplyReload failed: %v", err) + } + + if !updated { + t.Error("Expected updated to be true") + } + + // Verify env var was added + containers := accessor.GetContainers() + if len(containers) == 0 { + t.Fatal("No containers found") + } + + found := false + for _, env := range containers[0].Env { + if env.Name == "STAKATER_TEST_CM_CONFIGMAP" && env.Value == "abc123hash" { + found = true + break + } + } + + if !found { + t.Error("Expected env var STAKATER_TEST_CM_CONFIGMAP to be set") + } + + // Verify attribution annotation was set + annotations := accessor.GetPodTemplateAnnotations() + if annotations["reloader.stakater.com/last-reloaded-from"] == "" { + t.Error("Expected last-reloaded-from annotation to be set") + } +} + +func TestService_ApplyReload_AnnotationStrategy(t *testing.T) { + cfg := config.NewDefault() + cfg.ReloadStrategy = config.ReloadStrategyAnnotations + svc := NewService(cfg) + + deploy := createTestDeployment("test-deploy", "default", nil) + accessor := workload.NewDeploymentWorkload(deploy) + + ctx := context.Background() + updated, err := svc.ApplyReload(ctx, accessor, "test-cm", ResourceTypeConfigMap, "default", "abc123hash", false) + + if err != nil { + t.Fatalf("ApplyReload failed: %v", err) + } + + if !updated { + t.Error("Expected updated to be true") + } + + // Verify annotation was added + annotations := accessor.GetPodTemplateAnnotations() + if annotations["reloader.stakater.com/last-reloaded-from"] == "" { + t.Error("Expected last-reloaded-from annotation to be set") + } +} + +func TestService_ApplyReload_EnvVarDeletion(t *testing.T) { + cfg := config.NewDefault() + cfg.ReloadStrategy = config.ReloadStrategyEnvVars + svc := NewService(cfg) + + deploy := createTestDeployment("test-deploy", "default", nil) + // Pre-add an env var + deploy.Spec.Template.Spec.Containers[0].Env = []corev1.EnvVar{ + {Name: "STAKATER_TEST_CM_CONFIGMAP", Value: "oldhash"}, + {Name: "OTHER_VAR", Value: "keep"}, + } + accessor := workload.NewDeploymentWorkload(deploy) + + ctx := context.Background() + // Empty hash signals deletion + updated, err := svc.ApplyReload(ctx, accessor, "test-cm", ResourceTypeConfigMap, "default", "", false) + + if err != nil { + t.Fatalf("ApplyReload failed: %v", err) + } + + if !updated { + t.Error("Expected updated to be true for env var removal") + } + + // Verify env var was removed + containers := accessor.GetContainers() + for _, env := range containers[0].Env { + if env.Name == "STAKATER_TEST_CM_CONFIGMAP" { + t.Error("Expected env var STAKATER_TEST_CM_CONFIGMAP to be removed") + } + } + + // Verify other env var was kept + found := false + for _, env := range containers[0].Env { + if env.Name == "OTHER_VAR" { + found = true + break + } + } + if !found { + t.Error("Expected OTHER_VAR to be kept") + } +} + +func TestService_ApplyReload_NoChangeIfSameHash(t *testing.T) { + cfg := config.NewDefault() + cfg.ReloadStrategy = config.ReloadStrategyEnvVars + svc := NewService(cfg) + + deploy := createTestDeployment("test-deploy", "default", nil) + // Pre-add env var with same hash + deploy.Spec.Template.Spec.Containers[0].Env = []corev1.EnvVar{ + {Name: "STAKATER_TEST_CM_CONFIGMAP", Value: "abc123hash"}, + } + accessor := workload.NewDeploymentWorkload(deploy) + + ctx := context.Background() + updated, err := svc.ApplyReload(ctx, accessor, "test-cm", ResourceTypeConfigMap, "default", "abc123hash", false) + + if err != nil { + t.Fatalf("ApplyReload failed: %v", err) + } + + if updated { + t.Error("Expected updated to be false when hash is unchanged") + } +} + +func TestService_ProcessConfigMap_MultipleWorkloads(t *testing.T) { + cfg := config.NewDefault() + svc := NewService(cfg) + + // Create multiple workloads + deploy1 := createTestDeployment("deploy1", "default", map[string]string{ + "reloader.stakater.com/auto": "true", + }) + deploy1.Spec.Template.Spec.Volumes = []corev1.Volume{ + { + Name: "config-vol", + VolumeSource: corev1.VolumeSource{ + ConfigMap: &corev1.ConfigMapVolumeSource{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: "shared-cm", + }, + }, + }, + }, + } + + deploy2 := createTestDeployment("deploy2", "default", map[string]string{ + "reloader.stakater.com/auto": "true", + }) + deploy2.Spec.Template.Spec.Volumes = []corev1.Volume{ + { + Name: "config-vol", + VolumeSource: corev1.VolumeSource{ + ConfigMap: &corev1.ConfigMapVolumeSource{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: "shared-cm", + }, + }, + }, + }, + } + + // Deploy3 doesn't use the configmap + deploy3 := createTestDeployment("deploy3", "default", map[string]string{ + "reloader.stakater.com/auto": "true", + }) + + workloads := []workload.WorkloadAccessor{ + workload.NewDeploymentWorkload(deploy1), + workload.NewDeploymentWorkload(deploy2), + workload.NewDeploymentWorkload(deploy3), + } + + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "shared-cm", + Namespace: "default", + }, + Data: map[string]string{"key": "value"}, + } + + change := ConfigMapChange{ + ConfigMap: cm, + EventType: EventTypeUpdate, + } + + decisions := svc.ProcessConfigMap(change, workloads) + + if len(decisions) != 3 { + t.Fatalf("Expected 3 decisions, got %d", len(decisions)) + } + + // Count how many should reload + reloadCount := 0 + for _, d := range decisions { + if d.ShouldReload { + reloadCount++ + } + } + + // Only deploy1 and deploy2 should reload (they use the configmap) + if reloadCount != 2 { + t.Errorf("Expected 2 workloads to reload, got %d", reloadCount) + } +} + +func TestService_ProcessConfigMap_DifferentNamespaces(t *testing.T) { + cfg := config.NewDefault() + svc := NewService(cfg) + + // Create deployments in different namespaces + deploy1 := createTestDeployment("deploy1", "namespace-a", map[string]string{ + "reloader.stakater.com/auto": "true", + }) + deploy1.Spec.Template.Spec.Volumes = []corev1.Volume{ + { + Name: "config-vol", + VolumeSource: corev1.VolumeSource{ + ConfigMap: &corev1.ConfigMapVolumeSource{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: "test-cm", + }, + }, + }, + }, + } + + deploy2 := createTestDeployment("deploy2", "namespace-b", map[string]string{ + "reloader.stakater.com/auto": "true", + }) + deploy2.Spec.Template.Spec.Volumes = []corev1.Volume{ + { + Name: "config-vol", + VolumeSource: corev1.VolumeSource{ + ConfigMap: &corev1.ConfigMapVolumeSource{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: "test-cm", + }, + }, + }, + }, + } + + workloads := []workload.WorkloadAccessor{ + workload.NewDeploymentWorkload(deploy1), + workload.NewDeploymentWorkload(deploy2), + } + + // ConfigMap in namespace-a + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-cm", + Namespace: "namespace-a", + }, + Data: map[string]string{"key": "value"}, + } + + change := ConfigMapChange{ + ConfigMap: cm, + EventType: EventTypeUpdate, + } + + decisions := svc.ProcessConfigMap(change, workloads) + + // Should only affect deploy1 (same namespace) + reloadCount := 0 + for _, d := range decisions { + if d.ShouldReload { + reloadCount++ + } + } + + if reloadCount != 1 { + t.Errorf("Expected 1 workload to reload (same namespace), got %d", reloadCount) + } +} + +// Helper function to create a test deployment +func createTestDeployment(name, namespace string, annotations map[string]string) *appsv1.Deployment { + replicas := int32(1) + return &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + Annotations: annotations, + }, + Spec: appsv1.DeploymentSpec{ + Replicas: &replicas, + Selector: &metav1.LabelSelector{ + MatchLabels: map[string]string{"app": name}, + }, + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{"app": name}, + Annotations: map[string]string{}, + }, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "main", + Image: "nginx:latest", + }, + }, + }, + }, + }, + } +} diff --git a/internal/pkg/reload/strategy.go b/internal/pkg/reload/strategy.go index ce938a7e..9a147fe2 100644 --- a/internal/pkg/reload/strategy.go +++ b/internal/pkg/reload/strategy.go @@ -74,7 +74,8 @@ func (s *EnvVarStrategy) Name() string { return string(config.ReloadStrategyEnvVars) } -// Apply adds or updates an environment variable to trigger a restart. +// Apply adds, updates, or removes an environment variable to trigger a restart. +// When hash is empty (resource deleted), the env var is removed. func (s *EnvVarStrategy) Apply(input StrategyInput) (bool, error) { if input.Container == nil { return false, fmt.Errorf("container is required for env-var strategy") @@ -82,6 +83,11 @@ func (s *EnvVarStrategy) Apply(input StrategyInput) (bool, error) { envVarName := s.envVarName(input.ResourceName, input.ResourceType) + // Handle deletion: remove the env var when hash is empty + if input.Hash == "" { + return s.removeEnvVar(input.Container, envVarName), nil + } + // Check if env var already exists for i := range input.Container.Env { if input.Container.Env[i].Name == envVarName { @@ -104,6 +110,20 @@ func (s *EnvVarStrategy) Apply(input StrategyInput) (bool, error) { return true, nil } +// removeEnvVar removes an environment variable from a container. +// Returns true if a variable was removed. +func (s *EnvVarStrategy) removeEnvVar(container *corev1.Container, name string) bool { + for i := range container.Env { + if container.Env[i].Name == name { + // Remove by replacing with last element and truncating + container.Env[i] = container.Env[len(container.Env)-1] + container.Env = container.Env[:len(container.Env)-1] + return true + } + } + return false +} + // envVarName generates the environment variable name for a resource. func (s *EnvVarStrategy) envVarName(resourceName string, resourceType ResourceType) string { var postfix string