mirror of
https://github.com/stakater/Reloader.git
synced 2026-08-20 12:36:26 +00:00
refactoring
This commit is contained in:
@@ -182,6 +182,26 @@ func BindFlags(fs *pflag.FlagSet, cfg *Config) {
|
||||
"secret-annotation", cfg.Annotations.SecretReload,
|
||||
"Annotation to detect changes in secrets, specified by name",
|
||||
)
|
||||
fs.String(
|
||||
"configmap-exclude-annotation", cfg.Annotations.ConfigmapExclude,
|
||||
"Annotation to exclude named configmaps from triggering reloads",
|
||||
)
|
||||
fs.String(
|
||||
"secret-exclude-annotation", cfg.Annotations.SecretExclude,
|
||||
"Annotation to exclude named secrets from triggering reloads",
|
||||
)
|
||||
fs.String(
|
||||
"secretproviderclass-auto-annotation", cfg.Annotations.SecretProviderClassAuto,
|
||||
"Annotation to detect changes in secret provider classes (CSI)",
|
||||
)
|
||||
fs.String(
|
||||
"secretproviderclass-annotation", cfg.Annotations.SecretProviderClassReload,
|
||||
"Annotation to detect changes in secret provider classes (CSI), specified by name",
|
||||
)
|
||||
fs.String(
|
||||
"secretproviderclass-exclude-annotation", cfg.Annotations.SecretProviderClassExclude,
|
||||
"Annotation to exclude named secret provider classes (CSI) from triggering reloads",
|
||||
)
|
||||
fs.String(
|
||||
"auto-search-annotation", cfg.Annotations.Search,
|
||||
"Annotation to detect changes in configmaps or secrets tagged with special match annotation",
|
||||
@@ -190,6 +210,10 @@ func BindFlags(fs *pflag.FlagSet, cfg *Config) {
|
||||
"search-match-annotation", cfg.Annotations.Match,
|
||||
"Annotation to mark secrets or configmaps to match the search",
|
||||
)
|
||||
fs.String(
|
||||
"ignore-annotation", cfg.Annotations.Ignore,
|
||||
"Annotation to ignore changes on watched resources",
|
||||
)
|
||||
fs.String(
|
||||
"pause-deployment-annotation", cfg.Annotations.PausePeriod,
|
||||
"Annotation to define the time period to pause a deployment after a configmap/secret change",
|
||||
@@ -289,23 +313,17 @@ func ApplyFlags(cfg *Config) error {
|
||||
cfg.Annotations.SecretAuto = v.GetString("secret-auto-annotation")
|
||||
cfg.Annotations.ConfigmapReload = v.GetString("configmap-annotation")
|
||||
cfg.Annotations.SecretReload = v.GetString("secret-annotation")
|
||||
cfg.Annotations.ConfigmapExclude = v.GetString("configmap-exclude-annotation")
|
||||
cfg.Annotations.SecretExclude = v.GetString("secret-exclude-annotation")
|
||||
cfg.Annotations.SecretProviderClassAuto = v.GetString("secretproviderclass-auto-annotation")
|
||||
cfg.Annotations.SecretProviderClassReload = v.GetString("secretproviderclass-annotation")
|
||||
cfg.Annotations.SecretProviderClassExclude = v.GetString("secretproviderclass-exclude-annotation")
|
||||
cfg.Annotations.Search = v.GetString("auto-search-annotation")
|
||||
cfg.Annotations.Match = v.GetString("search-match-annotation")
|
||||
cfg.Annotations.Ignore = v.GetString("ignore-annotation")
|
||||
cfg.Annotations.PausePeriod = v.GetString("pause-deployment-annotation")
|
||||
cfg.Annotations.PausedAt = v.GetString("pause-deployment-time-annotation")
|
||||
|
||||
// SecretProviderClass annotations have no dedicated CLI flag (parity with
|
||||
// master); keep the configured defaults.
|
||||
if cfg.Annotations.SecretProviderClassAuto == "" {
|
||||
cfg.Annotations.SecretProviderClassAuto = DefaultAnnotations().SecretProviderClassAuto
|
||||
}
|
||||
if cfg.Annotations.SecretProviderClassReload == "" {
|
||||
cfg.Annotations.SecretProviderClassReload = DefaultAnnotations().SecretProviderClassReload
|
||||
}
|
||||
if cfg.Annotations.SecretProviderClassExclude == "" {
|
||||
cfg.Annotations.SecretProviderClassExclude = DefaultAnnotations().SecretProviderClassExclude
|
||||
}
|
||||
|
||||
// Alerting
|
||||
cfg.Alerting.Enabled = v.GetBool("alert-on-reload")
|
||||
cfg.Alerting.WebhookURL = v.GetString("alert-webhook-url")
|
||||
|
||||
@@ -55,8 +55,14 @@ func TestBindFlags(t *testing.T) {
|
||||
"secret-auto-annotation",
|
||||
"configmap-annotation",
|
||||
"secret-annotation",
|
||||
"configmap-exclude-annotation",
|
||||
"secret-exclude-annotation",
|
||||
"secretproviderclass-auto-annotation",
|
||||
"secretproviderclass-annotation",
|
||||
"secretproviderclass-exclude-annotation",
|
||||
"auto-search-annotation",
|
||||
"search-match-annotation",
|
||||
"ignore-annotation",
|
||||
"pause-deployment-annotation",
|
||||
"pause-deployment-time-annotation",
|
||||
"watch-namespace",
|
||||
@@ -153,6 +159,131 @@ func TestBindFlags_CustomValues(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyFlags_SecretProviderClassAnnotations(t *testing.T) {
|
||||
// Defaults are preserved when the flags are not provided.
|
||||
resetViper()
|
||||
cfg := NewDefault()
|
||||
fs := pflag.NewFlagSet("test", pflag.ContinueOnError)
|
||||
BindFlags(fs, cfg)
|
||||
if err := fs.Parse(nil); err != nil {
|
||||
t.Fatalf("Parse() error = %v", err)
|
||||
}
|
||||
if err := ApplyFlags(cfg); err != nil {
|
||||
t.Fatalf("ApplyFlags() error = %v", err)
|
||||
}
|
||||
defaults := DefaultAnnotations()
|
||||
if cfg.Annotations.SecretProviderClassAuto != defaults.SecretProviderClassAuto {
|
||||
t.Errorf("SecretProviderClassAuto = %q, want default %q", cfg.Annotations.SecretProviderClassAuto, defaults.SecretProviderClassAuto)
|
||||
}
|
||||
if cfg.Annotations.SecretProviderClassReload != defaults.SecretProviderClassReload {
|
||||
t.Errorf("SecretProviderClassReload = %q, want default %q", cfg.Annotations.SecretProviderClassReload, defaults.SecretProviderClassReload)
|
||||
}
|
||||
if cfg.Annotations.SecretProviderClassExclude != defaults.SecretProviderClassExclude {
|
||||
t.Errorf("SecretProviderClassExclude = %q, want default %q", cfg.Annotations.SecretProviderClassExclude, defaults.SecretProviderClassExclude)
|
||||
}
|
||||
|
||||
// Custom values are applied from the flags.
|
||||
resetViper()
|
||||
cfg = NewDefault()
|
||||
fs = pflag.NewFlagSet("test", pflag.ContinueOnError)
|
||||
BindFlags(fs, cfg)
|
||||
args := []string{
|
||||
"--secretproviderclass-auto-annotation=spc.example.com/auto",
|
||||
"--secretproviderclass-annotation=spc.example.com/reload",
|
||||
"--secretproviderclass-exclude-annotation=spc.example.com/exclude",
|
||||
}
|
||||
if err := fs.Parse(args); err != nil {
|
||||
t.Fatalf("Parse() error = %v", err)
|
||||
}
|
||||
if err := ApplyFlags(cfg); err != nil {
|
||||
t.Fatalf("ApplyFlags() error = %v", err)
|
||||
}
|
||||
if cfg.Annotations.SecretProviderClassAuto != "spc.example.com/auto" {
|
||||
t.Errorf("SecretProviderClassAuto = %q, want %q", cfg.Annotations.SecretProviderClassAuto, "spc.example.com/auto")
|
||||
}
|
||||
if cfg.Annotations.SecretProviderClassReload != "spc.example.com/reload" {
|
||||
t.Errorf("SecretProviderClassReload = %q, want %q", cfg.Annotations.SecretProviderClassReload, "spc.example.com/reload")
|
||||
}
|
||||
if cfg.Annotations.SecretProviderClassExclude != "spc.example.com/exclude" {
|
||||
t.Errorf("SecretProviderClassExclude = %q, want %q", cfg.Annotations.SecretProviderClassExclude, "spc.example.com/exclude")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyFlags_ExcludeAnnotations(t *testing.T) {
|
||||
// Defaults are preserved when the flags are not provided.
|
||||
resetViper()
|
||||
cfg := NewDefault()
|
||||
fs := pflag.NewFlagSet("test", pflag.ContinueOnError)
|
||||
BindFlags(fs, cfg)
|
||||
if err := fs.Parse(nil); err != nil {
|
||||
t.Fatalf("Parse() error = %v", err)
|
||||
}
|
||||
if err := ApplyFlags(cfg); err != nil {
|
||||
t.Fatalf("ApplyFlags() error = %v", err)
|
||||
}
|
||||
defaults := DefaultAnnotations()
|
||||
if cfg.Annotations.ConfigmapExclude != defaults.ConfigmapExclude {
|
||||
t.Errorf("ConfigmapExclude = %q, want default %q", cfg.Annotations.ConfigmapExclude, defaults.ConfigmapExclude)
|
||||
}
|
||||
if cfg.Annotations.SecretExclude != defaults.SecretExclude {
|
||||
t.Errorf("SecretExclude = %q, want default %q", cfg.Annotations.SecretExclude, defaults.SecretExclude)
|
||||
}
|
||||
|
||||
// Custom values are applied from the flags.
|
||||
resetViper()
|
||||
cfg = NewDefault()
|
||||
fs = pflag.NewFlagSet("test", pflag.ContinueOnError)
|
||||
BindFlags(fs, cfg)
|
||||
args := []string{
|
||||
"--configmap-exclude-annotation=cm.example.com/exclude",
|
||||
"--secret-exclude-annotation=sec.example.com/exclude",
|
||||
}
|
||||
if err := fs.Parse(args); err != nil {
|
||||
t.Fatalf("Parse() error = %v", err)
|
||||
}
|
||||
if err := ApplyFlags(cfg); err != nil {
|
||||
t.Fatalf("ApplyFlags() error = %v", err)
|
||||
}
|
||||
if cfg.Annotations.ConfigmapExclude != "cm.example.com/exclude" {
|
||||
t.Errorf("ConfigmapExclude = %q, want %q", cfg.Annotations.ConfigmapExclude, "cm.example.com/exclude")
|
||||
}
|
||||
if cfg.Annotations.SecretExclude != "sec.example.com/exclude" {
|
||||
t.Errorf("SecretExclude = %q, want %q", cfg.Annotations.SecretExclude, "sec.example.com/exclude")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyFlags_IgnoreAnnotation(t *testing.T) {
|
||||
// Default is preserved when the flag is not provided.
|
||||
resetViper()
|
||||
cfg := NewDefault()
|
||||
fs := pflag.NewFlagSet("test", pflag.ContinueOnError)
|
||||
BindFlags(fs, cfg)
|
||||
if err := fs.Parse(nil); err != nil {
|
||||
t.Fatalf("Parse() error = %v", err)
|
||||
}
|
||||
if err := ApplyFlags(cfg); err != nil {
|
||||
t.Fatalf("ApplyFlags() error = %v", err)
|
||||
}
|
||||
if cfg.Annotations.Ignore != DefaultAnnotations().Ignore {
|
||||
t.Errorf("Ignore = %q, want default %q", cfg.Annotations.Ignore, DefaultAnnotations().Ignore)
|
||||
}
|
||||
|
||||
// Custom value is applied from the flag.
|
||||
resetViper()
|
||||
cfg = NewDefault()
|
||||
fs = pflag.NewFlagSet("test", pflag.ContinueOnError)
|
||||
BindFlags(fs, cfg)
|
||||
if err := fs.Parse([]string{"--ignore-annotation=my.company.com/reloader-ignore"}); err != nil {
|
||||
t.Fatalf("Parse() error = %v", err)
|
||||
}
|
||||
if err := ApplyFlags(cfg); err != nil {
|
||||
t.Fatalf("ApplyFlags() error = %v", err)
|
||||
}
|
||||
if cfg.Annotations.Ignore != "my.company.com/reloader-ignore" {
|
||||
t.Errorf("Ignore = %q, want %q", cfg.Annotations.Ignore, "my.company.com/reloader-ignore")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyFlags_BooleanStrings(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
||||
@@ -246,7 +246,7 @@ func SetupReconcilers(mgr ctrl.Manager, cfg *config.Config, log logr.Logger, col
|
||||
},
|
||||
mgr.GetAPIReader(),
|
||||
)
|
||||
if err := spcReconciler.SetupWithManager(mgr); err != nil {
|
||||
if err := SetupSecretProviderClassReconciler(mgr, spcReconciler); err != nil {
|
||||
return fmt.Errorf("setting up secretproviderclass reconciler: %w", err)
|
||||
}
|
||||
log.Info("CSI SecretProviderClass reconciler enabled")
|
||||
|
||||
@@ -21,7 +21,9 @@ import (
|
||||
|
||||
// ResourceReconcilerDeps holds shared dependencies for resource reconcilers.
|
||||
type ResourceReconcilerDeps struct {
|
||||
Client client.Client
|
||||
Client client.Client
|
||||
// APIReader is an optional non-cached reader for ResolveChange lookups.
|
||||
APIReader client.Reader
|
||||
Log logr.Logger
|
||||
Config *config.Config
|
||||
ReloadService *reload.Service
|
||||
@@ -47,6 +49,16 @@ type ResourceConfig[T client.Object] struct {
|
||||
|
||||
// CreatePredicates creates the predicates for this resource type.
|
||||
CreatePredicates func(cfg *config.Config, hasher *reload.Hasher) predicate.Predicate
|
||||
|
||||
// ResolveChange derives the change from a second object instead of CreateChange;
|
||||
// ok=false skips the event (CSI: change comes from the parent SecretProviderClass).
|
||||
ResolveChange func(ctx context.Context, reader client.Reader, log logr.Logger, resource T) (reload.ResourceChange, bool)
|
||||
|
||||
// SkipOnNotFound treats a missing object as a no-op (CSI deletes SPCPS as pods roll).
|
||||
SkipOnNotFound bool
|
||||
|
||||
// BuildFilter overrides the default BuildEventFilter for the watch.
|
||||
BuildFilter func(cfg *config.Config, hasher *reload.Hasher) predicate.Predicate
|
||||
}
|
||||
|
||||
// ResourceReconciler is a generic reconciler for ConfigMaps and Secrets.
|
||||
@@ -102,10 +114,17 @@ func (r *ResourceReconciler[T]) Reconcile(ctx context.Context, req ctrl.Request)
|
||||
return ctrl.Result{}, nil
|
||||
}
|
||||
|
||||
change, ok := r.buildChange(ctx, log, resource)
|
||||
if !ok {
|
||||
r.Collectors.RecordSkipped("resolve_skipped")
|
||||
r.Collectors.RecordReconcile("success", time.Since(startTime))
|
||||
return ctrl.Result{}, nil
|
||||
}
|
||||
|
||||
result, err := r.reloadHandler().Process(
|
||||
ctx, req.Namespace, req.Name, r.ResourceType,
|
||||
ctx, change.GetNamespace(), change.GetName(), r.ResourceType,
|
||||
func(workloads []workload.Workload) []reload.ReloadDecision {
|
||||
return r.ReloadService.Process(r.CreateChange(resource, reload.EventTypeUpdate), workloads)
|
||||
return r.ReloadService.Process(change, workloads)
|
||||
}, log,
|
||||
)
|
||||
|
||||
@@ -113,12 +132,25 @@ func (r *ResourceReconciler[T]) Reconcile(ctx context.Context, req ctrl.Request)
|
||||
return result, err
|
||||
}
|
||||
|
||||
// buildChange returns the change via ResolveChange when set, else CreateChange.
|
||||
func (r *ResourceReconciler[T]) buildChange(ctx context.Context, log logr.Logger, resource T) (reload.ResourceChange, bool) {
|
||||
if r.ResolveChange != nil {
|
||||
return r.ResolveChange(ctx, r.APIReader, log, resource)
|
||||
}
|
||||
return r.CreateChange(resource, reload.EventTypeUpdate), true
|
||||
}
|
||||
|
||||
func (r *ResourceReconciler[T]) handleNotFound(
|
||||
ctx context.Context,
|
||||
req ctrl.Request,
|
||||
log logr.Logger,
|
||||
startTime time.Time,
|
||||
) (ctrl.Result, error) {
|
||||
if r.SkipOnNotFound {
|
||||
r.Collectors.RecordSkipped("not_found")
|
||||
r.Collectors.RecordReconcile("success", time.Since(startTime))
|
||||
return ctrl.Result{}, nil
|
||||
}
|
||||
if r.Config.ReloadOnDelete {
|
||||
r.Collectors.RecordEventReceived("delete", string(r.ResourceType))
|
||||
result, err := r.handleDelete(ctx, req, log)
|
||||
@@ -176,19 +208,19 @@ func (r *ResourceReconciler[T]) reloadHandler() *ReloadHandler {
|
||||
|
||||
// SetupWithManager sets up the controller with the Manager.
|
||||
func (r *ResourceReconciler[T]) SetupWithManager(mgr ctrl.Manager, forObject T) error {
|
||||
// Capture the moment the controller is wired up (before the manager starts
|
||||
// watching). Resources that already exist are replayed during the initial
|
||||
// cache sync with an older creation timestamp; the create predicate uses
|
||||
// this to ignore those replays while still honoring genuine creates that
|
||||
// arrive afterwards.
|
||||
startTime := time.Now()
|
||||
var filter predicate.Predicate
|
||||
if r.BuildFilter != nil {
|
||||
filter = r.BuildFilter(r.Config, r.ReloadService.Hasher())
|
||||
} else {
|
||||
// time.Now() lets the create predicate ignore initial-sync replays of
|
||||
// pre-existing resources (older creation timestamps) while honoring later creates.
|
||||
filter = BuildEventFilter(
|
||||
r.CreatePredicates(r.Config, r.ReloadService.Hasher()),
|
||||
r.Config, time.Now(),
|
||||
)
|
||||
}
|
||||
return ctrl.NewControllerManagedBy(mgr).
|
||||
For(forObject).
|
||||
WithEventFilter(
|
||||
BuildEventFilter(
|
||||
r.CreatePredicates(r.Config, r.ReloadService.Hasher()),
|
||||
r.Config, startTime,
|
||||
),
|
||||
).
|
||||
WithEventFilter(filter).
|
||||
Complete(r)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/go-logr/logr"
|
||||
"k8s.io/apimachinery/pkg/labels"
|
||||
"sigs.k8s.io/controller-runtime/pkg/event"
|
||||
csiv1 "sigs.k8s.io/secrets-store-csi-driver/apis/v1"
|
||||
|
||||
"github.com/stakater/Reloader/internal/pkg/config"
|
||||
"github.com/stakater/Reloader/internal/pkg/reload"
|
||||
)
|
||||
|
||||
// TestSecretProviderClassReconciler_FilterIgnoresResourceLabelSelector pins the
|
||||
// deliberate behavior that --resource-label-selector does NOT filter
|
||||
// SecretProviderClassPodStatus events (they are CSI-driver-owned and cannot carry
|
||||
// user labels). If the filter ever regressed to BuildEventFilter (which applies
|
||||
// LabelSelectorPredicate), the changed-status event below would be dropped.
|
||||
func TestSecretProviderClassReconciler_FilterIgnoresResourceLabelSelector(t *testing.T) {
|
||||
cfg := config.NewDefault()
|
||||
sel, err := labels.Parse("reloader=enabled")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg.ResourceSelectors = []labels.Selector{sel}
|
||||
|
||||
r := NewSecretProviderClassReconciler(
|
||||
ResourceReconcilerDeps{
|
||||
Config: cfg,
|
||||
Log: logr.Discard(),
|
||||
ReloadService: reload.NewService(cfg, logr.Discard()),
|
||||
},
|
||||
nil,
|
||||
)
|
||||
|
||||
// SPCPS with a changed status and NO matching label.
|
||||
oldObj := &csiv1.SecretProviderClassPodStatus{
|
||||
Status: csiv1.SecretProviderClassPodStatusStatus{
|
||||
SecretProviderClassName: "spc",
|
||||
Objects: []csiv1.SecretProviderClassObject{{ID: "a", Version: "1"}},
|
||||
},
|
||||
}
|
||||
newObj := &csiv1.SecretProviderClassPodStatus{
|
||||
Status: csiv1.SecretProviderClassPodStatusStatus{
|
||||
SecretProviderClassName: "spc",
|
||||
Objects: []csiv1.SecretProviderClassObject{{ID: "a", Version: "2"}},
|
||||
},
|
||||
}
|
||||
|
||||
filter := r.BuildFilter(r.Config, r.ReloadService.Hasher())
|
||||
if !filter.Update(event.UpdateEvent{ObjectOld: oldObj, ObjectNew: newObj}) {
|
||||
t.Fatal("SPCPS status change must pass the filter even when --resource-label-selector is set")
|
||||
}
|
||||
}
|
||||
@@ -2,168 +2,90 @@ package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/go-logr/logr"
|
||||
"k8s.io/apimachinery/pkg/api/errors"
|
||||
"k8s.io/apimachinery/pkg/types"
|
||||
ctrl "sigs.k8s.io/controller-runtime"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||
"sigs.k8s.io/controller-runtime/pkg/predicate"
|
||||
"sigs.k8s.io/controller-runtime/pkg/reconcile"
|
||||
csiv1 "sigs.k8s.io/secrets-store-csi-driver/apis/v1"
|
||||
|
||||
"github.com/stakater/Reloader/internal/pkg/config"
|
||||
"github.com/stakater/Reloader/internal/pkg/reload"
|
||||
"github.com/stakater/Reloader/internal/pkg/workload"
|
||||
)
|
||||
|
||||
// SecretProviderClassReconciler watches SecretProviderClassPodStatus objects and
|
||||
// triggers workload reloads when the secret versions they track change.
|
||||
//
|
||||
// It watches SecretProviderClassPodStatus (the per-pod status written by the CSI
|
||||
// driver) rather than SecretProviderClass directly, because only the pod status
|
||||
// carries the current object IDs and versions that indicate a secret rotation.
|
||||
type SecretProviderClassReconciler struct {
|
||||
ResourceReconcilerDeps
|
||||
// SecretProviderClassReconciler watches SecretProviderClassPodStatus (the per-pod
|
||||
// status the CSI driver rewrites on rotation) and reloads matching workloads,
|
||||
// reusing the generic reconciler via a ResolveChange hook.
|
||||
type SecretProviderClassReconciler = ResourceReconciler[*csiv1.SecretProviderClassPodStatus]
|
||||
|
||||
// apiReader is a direct API client (not cached) used to look up the parent
|
||||
// SecretProviderClass object. In tests this is set to the fake client.
|
||||
apiReader client.Reader
|
||||
|
||||
handler *ReloadHandler
|
||||
}
|
||||
|
||||
// NewSecretProviderClassReconciler creates a new SecretProviderClassReconciler.
|
||||
// NewSecretProviderClassReconciler builds the reconciler. apiReader (non-cached)
|
||||
// looks up the parent SecretProviderClass without starting a second informer.
|
||||
func NewSecretProviderClassReconciler(deps ResourceReconcilerDeps, apiReader client.Reader) *SecretProviderClassReconciler {
|
||||
return &SecretProviderClassReconciler{
|
||||
ResourceReconcilerDeps: deps,
|
||||
apiReader: apiReader,
|
||||
}
|
||||
deps.APIReader = apiReader
|
||||
return NewResourceReconciler(
|
||||
deps,
|
||||
ResourceConfig[*csiv1.SecretProviderClassPodStatus]{
|
||||
ResourceType: reload.ResourceTypeSecretProviderClass,
|
||||
NewResource: func() *csiv1.SecretProviderClassPodStatus { return &csiv1.SecretProviderClassPodStatus{} },
|
||||
ResolveChange: resolveSecretProviderClassChange,
|
||||
SkipOnNotFound: true,
|
||||
BuildFilter: secretProviderClassFilter,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// Reconcile handles a SecretProviderClassPodStatus event.
|
||||
func (r *SecretProviderClassReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
|
||||
startTime := time.Now()
|
||||
resourceType := string(reload.ResourceTypeSecretProviderClass)
|
||||
log := r.Log.WithValues("secretproviderclasspodstatus", req.NamespacedName)
|
||||
|
||||
r.Collectors.RecordEventReceived("reconcile", resourceType)
|
||||
|
||||
spcps := &csiv1.SecretProviderClassPodStatus{}
|
||||
if err := r.Client.Get(ctx, req.NamespacedName, spcps); err != nil {
|
||||
if errors.IsNotFound(err) {
|
||||
r.Collectors.RecordSkipped("not_found")
|
||||
r.Collectors.RecordReconcile("success", time.Since(startTime))
|
||||
return ctrl.Result{}, nil
|
||||
}
|
||||
log.Error(err, "failed to get SecretProviderClassPodStatus")
|
||||
r.Collectors.RecordError("get_secretproviderclasspodstatus")
|
||||
r.Collectors.RecordReconcile("error", time.Since(startTime))
|
||||
return ctrl.Result{}, err
|
||||
}
|
||||
|
||||
namespace := spcps.GetNamespace()
|
||||
if r.Config.IsNamespaceIgnored(namespace) {
|
||||
log.V(1).Info("skipping SecretProviderClassPodStatus in ignored namespace")
|
||||
r.Collectors.RecordSkipped("ignored_namespace")
|
||||
r.Collectors.RecordReconcile("success", time.Since(startTime))
|
||||
return ctrl.Result{}, nil
|
||||
}
|
||||
|
||||
if r.NamespaceCache != nil && r.NamespaceCache.IsEnabled() && !r.NamespaceCache.Contains(namespace) {
|
||||
log.V(1).Info("skipping SecretProviderClassPodStatus in namespace not matching selector", "namespace", namespace)
|
||||
r.Collectors.RecordSkipped("namespace_selector")
|
||||
r.Collectors.RecordReconcile("success", time.Since(startTime))
|
||||
return ctrl.Result{}, nil
|
||||
}
|
||||
|
||||
spcName, spcAnnotations := r.resolveSPCAnnotations(ctx, spcps)
|
||||
// resolveSecretProviderClassChange builds the change from an SPCPS: it reads the
|
||||
// SPC name from the status and looks up the SPC for its annotations. On any lookup
|
||||
// error it proceeds with empty annotations so annotation-matched workloads still
|
||||
// reload (master parity); an empty SPC name skips the event.
|
||||
func resolveSecretProviderClassChange(
|
||||
ctx context.Context,
|
||||
reader client.Reader,
|
||||
log logr.Logger,
|
||||
spcps *csiv1.SecretProviderClassPodStatus,
|
||||
) (reload.ResourceChange, bool) {
|
||||
spcName := spcps.Status.SecretProviderClassName
|
||||
if spcName == "" {
|
||||
r.Collectors.RecordSkipped("no_spc_name")
|
||||
r.Collectors.RecordReconcile("success", time.Since(startTime))
|
||||
return ctrl.Result{}, nil
|
||||
return nil, false
|
||||
}
|
||||
|
||||
change := reload.SecretProviderClassChange{
|
||||
annotations := map[string]string{}
|
||||
spc := &csiv1.SecretProviderClass{}
|
||||
if err := reader.Get(ctx, types.NamespacedName{Name: spcName, Namespace: spcps.GetNamespace()}, spc); err != nil {
|
||||
if errors.IsNotFound(err) {
|
||||
log.Info("SecretProviderClass not found; proceeding without its annotations", "spc", spcName)
|
||||
} else {
|
||||
log.V(1).Error(err, "failed to get SecretProviderClass; proceeding without its annotations", "spc", spcName)
|
||||
}
|
||||
} else if a := spc.GetAnnotations(); a != nil {
|
||||
annotations = a
|
||||
}
|
||||
|
||||
return reload.SecretProviderClassChange{
|
||||
Name: spcName,
|
||||
Namespace: namespace,
|
||||
Annotations: spcAnnotations,
|
||||
Namespace: spcps.GetNamespace(),
|
||||
Annotations: annotations,
|
||||
Status: spcps.Status,
|
||||
EventType: reload.EventTypeUpdate,
|
||||
}
|
||||
}, true
|
||||
}
|
||||
|
||||
result, err := r.reloadHandler().Process(
|
||||
ctx, namespace, spcName, reload.ResourceTypeSecretProviderClass,
|
||||
func(workloads []workload.Workload) []reload.ReloadDecision {
|
||||
return r.ReloadService.Process(change, workloads)
|
||||
}, log,
|
||||
// secretProviderClassFilter omits the label selector (driver-owned SPCPS can't
|
||||
// carry user labels) and the namespace cache (checked in Reconcile to avoid a
|
||||
// startup race). See docs/manual-testing-csi.md.
|
||||
func secretProviderClassFilter(cfg *config.Config, hasher *reload.Hasher) predicate.Predicate {
|
||||
return reload.CombinedPredicates(
|
||||
reload.NamespaceFilterPredicateWithCache(cfg, nil),
|
||||
reload.SecretProviderClassPodStatusPredicates(cfg, hasher),
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
r.Collectors.RecordReconcile("error", time.Since(startTime))
|
||||
} else {
|
||||
r.Collectors.RecordReconcile("success", time.Since(startTime))
|
||||
}
|
||||
return result, err
|
||||
}
|
||||
|
||||
// resolveSPCAnnotations looks up the SecretProviderClass referenced by the
|
||||
// given pod status and returns its name and annotations. It never returns an
|
||||
// error: on any Get failure it logs and returns the SPC name (from the pod
|
||||
// status) with an empty annotations map, so callers can still process workloads
|
||||
// that match via their own auto/named annotations. This matches master's
|
||||
// behaviour in populateAnnotationsFromSecretProviderClass.
|
||||
func (r *SecretProviderClassReconciler) resolveSPCAnnotations(
|
||||
ctx context.Context,
|
||||
spcps *csiv1.SecretProviderClassPodStatus,
|
||||
) (string, map[string]string) {
|
||||
spcName := spcps.Status.SecretProviderClassName
|
||||
spc := &csiv1.SecretProviderClass{}
|
||||
if err := r.apiReader.Get(ctx, types.NamespacedName{
|
||||
Name: spcName,
|
||||
Namespace: spcps.GetNamespace(),
|
||||
}, spc); err != nil {
|
||||
if errors.IsNotFound(err) {
|
||||
r.Log.WithValues("spc", spcName).Info("SecretProviderClass not found; proceeding without its annotations")
|
||||
} else {
|
||||
r.Log.V(1).Error(err, "failed to get SecretProviderClass; proceeding without its annotations", "spc", spcName)
|
||||
}
|
||||
return spcName, map[string]string{}
|
||||
}
|
||||
annotations := spc.GetAnnotations()
|
||||
if annotations == nil {
|
||||
annotations = map[string]string{}
|
||||
}
|
||||
return spc.Name, annotations
|
||||
}
|
||||
|
||||
func (r *SecretProviderClassReconciler) reloadHandler() *ReloadHandler {
|
||||
if r.handler == nil {
|
||||
r.handler = &ReloadHandler{
|
||||
Client: r.Client,
|
||||
Lister: workload.NewLister(r.Client, r.Registry, r.Config),
|
||||
ReloadService: r.ReloadService,
|
||||
WebhookClient: r.WebhookClient,
|
||||
Collectors: r.Collectors,
|
||||
EventRecorder: r.EventRecorder,
|
||||
Alerter: r.Alerter,
|
||||
PauseHandler: r.PauseHandler,
|
||||
}
|
||||
}
|
||||
return r.handler
|
||||
}
|
||||
|
||||
// SetupWithManager wires the reconciler to watch SecretProviderClassPodStatus.
|
||||
func (r *SecretProviderClassReconciler) SetupWithManager(mgr ctrl.Manager) error {
|
||||
var nsChecker reload.NamespaceChecker
|
||||
if r.NamespaceCache != nil {
|
||||
nsChecker = r.NamespaceCache
|
||||
}
|
||||
return ctrl.NewControllerManagedBy(mgr).
|
||||
For(&csiv1.SecretProviderClassPodStatus{}).
|
||||
WithEventFilter(reload.CombinedPredicates(
|
||||
reload.NamespaceFilterPredicateWithCache(r.Config, nsChecker),
|
||||
reload.SecretProviderClassPodStatusPredicates(r.Config, r.ReloadService.Hasher()),
|
||||
)).
|
||||
Complete(r)
|
||||
// SetupSecretProviderClassReconciler sets up the reconciler with the manager.
|
||||
func SetupSecretProviderClassReconciler(mgr ctrl.Manager, r *SecretProviderClassReconciler) error {
|
||||
return r.SetupWithManager(mgr, &csiv1.SecretProviderClassPodStatus{})
|
||||
}
|
||||
|
||||
var _ reconcile.Reconciler = &SecretProviderClassReconciler{}
|
||||
|
||||
@@ -169,3 +169,31 @@ func TestSecretProviderClassReconciler_SPCNotFound(t *testing.T) {
|
||||
expectedEnvVar, updated.Spec.Template.Spec.Containers[0].Env)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecretProviderClassReconciler_EmptySPCName(t *testing.T) {
|
||||
cfg := config.NewDefault()
|
||||
// SPCPS whose Status carries no SecretProviderClassName must be skipped cleanly.
|
||||
spcps := &csiv1.SecretProviderClassPodStatus{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: "orphan-spcps", Namespace: "default"},
|
||||
Status: csiv1.SecretProviderClassPodStatusStatus{},
|
||||
}
|
||||
deployment := testutil.NewDeployment("test-deployment", "default", map[string]string{
|
||||
cfg.Annotations.SecretProviderClassAuto: "true",
|
||||
})
|
||||
reconciler, cl := newSecretProviderClassReconcilerWithClient(t, cfg, deployment, spcps)
|
||||
|
||||
if _, err := reconciler.Reconcile(context.Background(), reconcileRequest("orphan-spcps", "default")); err != nil {
|
||||
t.Fatalf("Reconcile error: %v", err)
|
||||
}
|
||||
|
||||
// No reload should have happened (no SPC name to match against).
|
||||
updated := &appsv1.Deployment{}
|
||||
if err := cl.Get(context.Background(), types.NamespacedName{Namespace: "default", Name: "test-deployment"}, updated); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, c := range updated.Spec.Template.Spec.Containers {
|
||||
if len(c.Env) != 0 {
|
||||
t.Errorf("expected no env vars injected for empty SPC name, got %+v", c.Env)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -968,4 +968,19 @@ func TestSecretProviderClassPodStatusPredicates(t *testing.T) {
|
||||
if p.Update(event.UpdateEvent{ObjectOld: oldObj, ObjectNew: newObjSame}) {
|
||||
t.Fatal("UpdateFunc should return false on unchanged status")
|
||||
}
|
||||
|
||||
// A metadata/label-only change (same Status) must NOT trigger a reload,
|
||||
// since the predicate hashes only the status. (Master tested this via
|
||||
// UpdateSecretProviderClassPodStatusLabels.)
|
||||
labelOnly := oldObj.DeepCopy()
|
||||
labelOnly.Labels = map[string]string{"unrelated": "changed"}
|
||||
labelOnly.Annotations = map[string]string{"note": "touched"}
|
||||
if p.Update(event.UpdateEvent{ObjectOld: oldObj, ObjectNew: labelOnly}) {
|
||||
t.Fatal("UpdateFunc should return false on a label/metadata-only change")
|
||||
}
|
||||
|
||||
// Type-assertion failure (wrong object type) must be rejected, not panic.
|
||||
if p.Update(event.UpdateEvent{ObjectOld: &corev1.ConfigMap{}, ObjectNew: &corev1.ConfigMap{}}) {
|
||||
t.Fatal("UpdateFunc should return false when objects are not SPCPS")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -268,6 +268,11 @@ func (s *Service) findVolumeUsingResource(volumes []corev1.Volume, resourceName
|
||||
}
|
||||
}
|
||||
}
|
||||
case ResourceTypeSecretProviderClass:
|
||||
// Match the CSI volume that references this SPC.
|
||||
if vol.CSI != nil && vol.CSI.VolumeAttributes["secretProviderClass"] == resourceName {
|
||||
return vol.Name
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/go-logr/logr/testr"
|
||||
appsv1 "k8s.io/api/apps/v1"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
csiv1 "sigs.k8s.io/secrets-store-csi-driver/apis/v1"
|
||||
@@ -1385,3 +1386,88 @@ func TestService_ProcessCreateEventDisabled(t *testing.T) {
|
||||
t.Errorf("Expected nil decisions when create events disabled, got %v", decisions)
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_ApplyReload_SPC_TargetsMountingContainer(t *testing.T) {
|
||||
cfg := config.NewDefault()
|
||||
cfg.ReloadStrategy = config.ReloadStrategyEnvVars
|
||||
svc := NewService(cfg, testr.New(t))
|
||||
|
||||
// Two containers; only the second mounts the CSI volume that references the SPC.
|
||||
dep := &appsv1.Deployment{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: "multi", Namespace: "default"},
|
||||
Spec: appsv1.DeploymentSpec{
|
||||
Template: corev1.PodTemplateSpec{
|
||||
Spec: corev1.PodSpec{
|
||||
Containers: []corev1.Container{
|
||||
{Name: "c0"},
|
||||
{Name: "c1", VolumeMounts: []corev1.VolumeMount{{Name: "spc-vol", MountPath: "/mnt/secrets-store"}}},
|
||||
},
|
||||
Volumes: []corev1.Volume{
|
||||
{
|
||||
Name: "spc-vol",
|
||||
VolumeSource: corev1.VolumeSource{
|
||||
CSI: &corev1.CSIVolumeSource{
|
||||
Driver: "secrets-store.csi.k8s.io",
|
||||
VolumeAttributes: map[string]string{"secretProviderClass": "my-spc"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
accessor := workload.NewDeploymentWorkload(dep)
|
||||
|
||||
// autoReload=true exercises volume-based container targeting.
|
||||
updated, err := svc.ApplyReload(context.Background(), accessor, "my-spc", ResourceTypeSecretProviderClass, "default", "spchash", true)
|
||||
if err != nil {
|
||||
t.Fatalf("ApplyReload failed: %v", err)
|
||||
}
|
||||
if !updated {
|
||||
t.Fatal("expected updated=true")
|
||||
}
|
||||
|
||||
containers := accessor.GetContainers()
|
||||
const envName = "STAKATER_MY_SPC_SECRETPROVIDERCLASS"
|
||||
hasEnv := func(c corev1.Container) bool {
|
||||
for _, e := range c.Env {
|
||||
if e.Name == envName {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
if hasEnv(containers[0]) {
|
||||
t.Error("env var must NOT land on container[0] (it does not mount the SPC volume)")
|
||||
}
|
||||
if !hasEnv(containers[1]) {
|
||||
t.Error("env var must land on container[1], which mounts the SPC CSI volume")
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_ProcessSecretProviderClass_GenericAuto(t *testing.T) {
|
||||
cfg := config.NewDefault()
|
||||
svc := NewService(cfg, testr.New(t))
|
||||
|
||||
// Generic auto annotation (not the typed SPC one) must also trigger an SPC reload.
|
||||
deploy := testutil.NewDeployment("test-deploy", "default", map[string]string{
|
||||
"reloader.stakater.com/auto": "true",
|
||||
})
|
||||
workloads := []workload.Workload{workload.NewDeploymentWorkload(deploy)}
|
||||
|
||||
change := SecretProviderClassChange{
|
||||
Name: "my-spc",
|
||||
Namespace: "default",
|
||||
Status: csiv1.SecretProviderClassPodStatusStatus{
|
||||
SecretProviderClassName: "my-spc",
|
||||
Objects: []csiv1.SecretProviderClassObject{{ID: "a", Version: "1"}},
|
||||
},
|
||||
EventType: EventTypeUpdate,
|
||||
}
|
||||
|
||||
decisions := svc.Process(change, workloads)
|
||||
if len(decisions) != 1 || !decisions[0].ShouldReload {
|
||||
t.Fatalf("expected generic-auto SPC reload, got %+v", decisions)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -179,8 +179,15 @@ func (s *AnnotationStrategy) Apply(input StrategyInput) (bool, error) {
|
||||
annotationKey := s.cfg.Annotations.LastReloadedFrom
|
||||
existingValue := input.PodAnnotations[annotationKey]
|
||||
|
||||
if existingValue == string(sourceJSON) {
|
||||
return false, nil
|
||||
// Idempotent on kind+name+hash, ignoring ReloadedAt: a timestamped compare
|
||||
// would force a rollout every reconcile (one CSI rotation fans out to N
|
||||
// SecretProviderClassPodStatus updates → N rollouts).
|
||||
if existingValue != "" {
|
||||
var prev ReloadSource
|
||||
if err := json.Unmarshal([]byte(existingValue), &prev); err == nil &&
|
||||
prev.Kind == source.Kind && prev.Name == source.Name && prev.Hash == source.Hash {
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
|
||||
input.PodAnnotations[annotationKey] = string(sourceJSON)
|
||||
|
||||
@@ -255,6 +255,50 @@ func TestAnnotationStrategy_Apply(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("idempotent for same resource and hash (ignores timestamp)", func(t *testing.T) {
|
||||
annotations := make(map[string]string)
|
||||
input := StrategyInput{
|
||||
ResourceName: "my-config",
|
||||
ResourceType: ResourceTypeConfigMap,
|
||||
Namespace: "default",
|
||||
Hash: "abc123",
|
||||
Container: &corev1.Container{Name: "c"},
|
||||
PodAnnotations: annotations,
|
||||
}
|
||||
|
||||
changed, err := strategy.Apply(input)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !changed {
|
||||
t.Fatal("expected changed=true on first apply")
|
||||
}
|
||||
firstValue := annotations[cfg.Annotations.LastReloadedFrom]
|
||||
|
||||
// Re-applying the identical change must NOT report a change, even though
|
||||
// a fresh ReloadedAt timestamp would make the serialized value differ.
|
||||
changed, err = strategy.Apply(input)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if changed {
|
||||
t.Error("expected changed=false when re-applying the same resource+hash")
|
||||
}
|
||||
if annotations[cfg.Annotations.LastReloadedFrom] != firstValue {
|
||||
t.Error("annotation value must not change on an idempotent re-apply")
|
||||
}
|
||||
|
||||
// A different hash (real content change) must trigger an update.
|
||||
input.Hash = "def456"
|
||||
changed, err = strategy.Apply(input)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !changed {
|
||||
t.Error("expected changed=true when the hash changes")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("error when annotations map is nil", func(t *testing.T) {
|
||||
input := StrategyInput{
|
||||
ResourceName: "my-config",
|
||||
|
||||
Reference in New Issue
Block a user