mirror of
https://github.com/stakater/Reloader.git
synced 2026-08-20 12:36:26 +00:00
feat: reload execution and observability
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user