mirror of
https://github.com/stakater/Reloader.git
synced 2026-08-23 22:16:45 +00:00
feat: Implement NamespaceReconciler for namespace label selector filtering
This commit is contained in:
@@ -1,3 +1,6 @@
|
||||
//go:build integration
|
||||
// +build integration
|
||||
|
||||
package controller
|
||||
|
||||
import (
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
|
||||
"github.com/go-logr/logr"
|
||||
"github.com/stakater/Reloader/internal/pkg/config"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
"k8s.io/apimachinery/pkg/api/errors"
|
||||
ctrl "sigs.k8s.io/controller-runtime"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||
"sigs.k8s.io/controller-runtime/pkg/reconcile"
|
||||
)
|
||||
|
||||
// NamespaceCache provides thread-safe access to the set of namespaces
|
||||
// that match the configured namespace label selector.
|
||||
type NamespaceCache struct {
|
||||
mu sync.RWMutex
|
||||
namespaces map[string]struct{}
|
||||
enabled bool
|
||||
}
|
||||
|
||||
// NewNamespaceCache creates a new NamespaceCache.
|
||||
// If enabled is false, all namespace checks return true (allow all).
|
||||
func NewNamespaceCache(enabled bool) *NamespaceCache {
|
||||
return &NamespaceCache{
|
||||
namespaces: make(map[string]struct{}),
|
||||
enabled: enabled,
|
||||
}
|
||||
}
|
||||
|
||||
// Add adds a namespace to the cache.
|
||||
func (c *NamespaceCache) Add(name string) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.namespaces[name] = struct{}{}
|
||||
}
|
||||
|
||||
// Remove removes a namespace from the cache.
|
||||
func (c *NamespaceCache) Remove(name string) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
delete(c.namespaces, name)
|
||||
}
|
||||
|
||||
// Contains checks if a namespace is in the cache.
|
||||
// If namespace selectors are not enabled, always returns true.
|
||||
func (c *NamespaceCache) Contains(name string) bool {
|
||||
if !c.enabled {
|
||||
return true
|
||||
}
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
_, ok := c.namespaces[name]
|
||||
return ok
|
||||
}
|
||||
|
||||
// List returns a copy of all namespace names in the cache.
|
||||
func (c *NamespaceCache) List() []string {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
result := make([]string, 0, len(c.namespaces))
|
||||
for name := range c.namespaces {
|
||||
result = append(result, name)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// IsEnabled returns whether namespace selector filtering is enabled.
|
||||
func (c *NamespaceCache) IsEnabled() bool {
|
||||
return c.enabled
|
||||
}
|
||||
|
||||
// NamespaceReconciler watches Namespace objects and maintains a cache
|
||||
// of namespaces that match the configured label selector.
|
||||
type NamespaceReconciler struct {
|
||||
client.Client
|
||||
Log logr.Logger
|
||||
Config *config.Config
|
||||
Cache *NamespaceCache
|
||||
}
|
||||
|
||||
// Reconcile handles Namespace events and updates the namespace cache.
|
||||
func (r *NamespaceReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
|
||||
log := r.Log.WithValues("namespace", req.Name)
|
||||
|
||||
var ns corev1.Namespace
|
||||
if err := r.Get(ctx, req.NamespacedName, &ns); err != nil {
|
||||
if errors.IsNotFound(err) {
|
||||
// Namespace was deleted - remove from cache
|
||||
r.Cache.Remove(req.Name)
|
||||
log.V(1).Info("removed namespace from cache (deleted)")
|
||||
return ctrl.Result{}, nil
|
||||
}
|
||||
log.Error(err, "failed to get Namespace")
|
||||
return ctrl.Result{}, err
|
||||
}
|
||||
|
||||
// Check if namespace matches any of the configured selectors
|
||||
if r.matchesSelectors(&ns) {
|
||||
r.Cache.Add(ns.Name)
|
||||
log.V(1).Info("added namespace to cache")
|
||||
} else {
|
||||
// Labels might have changed, remove from cache if no longer matches
|
||||
r.Cache.Remove(ns.Name)
|
||||
log.V(1).Info("removed namespace from cache (labels no longer match)")
|
||||
}
|
||||
|
||||
return ctrl.Result{}, nil
|
||||
}
|
||||
|
||||
// matchesSelectors checks if the namespace matches any configured label selector.
|
||||
func (r *NamespaceReconciler) matchesSelectors(ns *corev1.Namespace) bool {
|
||||
if len(r.Config.NamespaceSelectors) == 0 {
|
||||
// No selectors configured - should not happen since reconciler is only
|
||||
// set up when selectors are configured, but handle gracefully
|
||||
return true
|
||||
}
|
||||
|
||||
nsLabels := ns.GetLabels()
|
||||
if nsLabels == nil {
|
||||
nsLabels = make(map[string]string)
|
||||
}
|
||||
|
||||
for _, selector := range r.Config.NamespaceSelectors {
|
||||
if selector.Matches(nsLabelsSet(nsLabels)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// nsLabelsSet implements labels.Labels interface for a map.
|
||||
type nsLabelsSet map[string]string
|
||||
|
||||
func (ls nsLabelsSet) Has(key string) bool {
|
||||
_, ok := ls[key]
|
||||
return ok
|
||||
}
|
||||
|
||||
func (ls nsLabelsSet) Get(key string) string {
|
||||
return ls[key]
|
||||
}
|
||||
|
||||
// SetupWithManager sets up the controller with the Manager.
|
||||
func (r *NamespaceReconciler) SetupWithManager(mgr ctrl.Manager) error {
|
||||
return ctrl.NewControllerManagedBy(mgr).
|
||||
For(&corev1.Namespace{}).
|
||||
Complete(r)
|
||||
}
|
||||
|
||||
// Ensure NamespaceReconciler implements reconcile.Reconciler
|
||||
var _ reconcile.Reconciler = &NamespaceReconciler{}
|
||||
@@ -0,0 +1,295 @@
|
||||
package controller_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/go-logr/logr/testr"
|
||||
"github.com/stakater/Reloader/internal/pkg/config"
|
||||
"github.com/stakater/Reloader/internal/pkg/controller"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/labels"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"k8s.io/apimachinery/pkg/types"
|
||||
ctrl "sigs.k8s.io/controller-runtime"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client/fake"
|
||||
)
|
||||
|
||||
func TestNamespaceCache_Basic(t *testing.T) {
|
||||
cache := controller.NewNamespaceCache(true)
|
||||
|
||||
// Test Add and Contains
|
||||
cache.Add("namespace-1")
|
||||
if !cache.Contains("namespace-1") {
|
||||
t.Error("Cache should contain namespace-1")
|
||||
}
|
||||
if cache.Contains("namespace-2") {
|
||||
t.Error("Cache should not contain namespace-2")
|
||||
}
|
||||
|
||||
// Test Remove
|
||||
cache.Remove("namespace-1")
|
||||
if cache.Contains("namespace-1") {
|
||||
t.Error("Cache should not contain namespace-1 after removal")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNamespaceCache_Disabled(t *testing.T) {
|
||||
cache := controller.NewNamespaceCache(false)
|
||||
|
||||
// When disabled, Contains should always return true
|
||||
if !cache.Contains("any-namespace") {
|
||||
t.Error("Disabled cache should return true for any namespace")
|
||||
}
|
||||
if !cache.Contains("other-namespace") {
|
||||
t.Error("Disabled cache should return true for any namespace")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNamespaceCache_List(t *testing.T) {
|
||||
cache := controller.NewNamespaceCache(true)
|
||||
cache.Add("ns-1")
|
||||
cache.Add("ns-2")
|
||||
cache.Add("ns-3")
|
||||
|
||||
list := cache.List()
|
||||
if len(list) != 3 {
|
||||
t.Errorf("Expected 3 namespaces, got %d", len(list))
|
||||
}
|
||||
|
||||
// Check all namespaces are in the list
|
||||
found := make(map[string]bool)
|
||||
for _, ns := range list {
|
||||
found[ns] = true
|
||||
}
|
||||
for _, expected := range []string{"ns-1", "ns-2", "ns-3"} {
|
||||
if !found[expected] {
|
||||
t.Errorf("Expected %s in list", expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNamespaceCache_IsEnabled(t *testing.T) {
|
||||
enabledCache := controller.NewNamespaceCache(true)
|
||||
disabledCache := controller.NewNamespaceCache(false)
|
||||
|
||||
if !enabledCache.IsEnabled() {
|
||||
t.Error("EnabledCache.IsEnabled() should return true")
|
||||
}
|
||||
if disabledCache.IsEnabled() {
|
||||
t.Error("DisabledCache.IsEnabled() should return false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNamespaceReconciler_Add(t *testing.T) {
|
||||
scheme := runtime.NewScheme()
|
||||
_ = corev1.AddToScheme(scheme)
|
||||
|
||||
ns := &corev1.Namespace{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-ns",
|
||||
Labels: map[string]string{"env": "production"},
|
||||
},
|
||||
}
|
||||
|
||||
fakeClient := fake.NewClientBuilder().
|
||||
WithScheme(scheme).
|
||||
WithObjects(ns).
|
||||
Build()
|
||||
|
||||
cfg := config.NewDefault()
|
||||
selector, _ := labels.Parse("env=production")
|
||||
cfg.NamespaceSelectors = []labels.Selector{selector}
|
||||
|
||||
cache := controller.NewNamespaceCache(true)
|
||||
reconciler := &controller.NamespaceReconciler{
|
||||
Client: fakeClient,
|
||||
Log: testr.New(t),
|
||||
Config: cfg,
|
||||
Cache: cache,
|
||||
}
|
||||
|
||||
req := ctrl.Request{
|
||||
NamespacedName: types.NamespacedName{Name: "test-ns"},
|
||||
}
|
||||
|
||||
_, err := reconciler.Reconcile(context.Background(), req)
|
||||
if err != nil {
|
||||
t.Fatalf("Reconcile failed: %v", err)
|
||||
}
|
||||
|
||||
if !cache.Contains("test-ns") {
|
||||
t.Error("Cache should contain test-ns after reconcile")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNamespaceReconciler_Remove_LabelChange(t *testing.T) {
|
||||
scheme := runtime.NewScheme()
|
||||
_ = corev1.AddToScheme(scheme)
|
||||
|
||||
// Namespace with non-matching labels
|
||||
ns := &corev1.Namespace{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-ns",
|
||||
Labels: map[string]string{"env": "staging"},
|
||||
},
|
||||
}
|
||||
|
||||
fakeClient := fake.NewClientBuilder().
|
||||
WithScheme(scheme).
|
||||
WithObjects(ns).
|
||||
Build()
|
||||
|
||||
cfg := config.NewDefault()
|
||||
selector, _ := labels.Parse("env=production")
|
||||
cfg.NamespaceSelectors = []labels.Selector{selector}
|
||||
|
||||
cache := controller.NewNamespaceCache(true)
|
||||
// Pre-populate cache
|
||||
cache.Add("test-ns")
|
||||
|
||||
reconciler := &controller.NamespaceReconciler{
|
||||
Client: fakeClient,
|
||||
Log: testr.New(t),
|
||||
Config: cfg,
|
||||
Cache: cache,
|
||||
}
|
||||
|
||||
req := ctrl.Request{
|
||||
NamespacedName: types.NamespacedName{Name: "test-ns"},
|
||||
}
|
||||
|
||||
_, err := reconciler.Reconcile(context.Background(), req)
|
||||
if err != nil {
|
||||
t.Fatalf("Reconcile failed: %v", err)
|
||||
}
|
||||
|
||||
if cache.Contains("test-ns") {
|
||||
t.Error("Cache should not contain test-ns after reconcile (labels no longer match)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNamespaceReconciler_Remove_Delete(t *testing.T) {
|
||||
scheme := runtime.NewScheme()
|
||||
_ = corev1.AddToScheme(scheme)
|
||||
|
||||
// No namespace in cluster (simulates delete)
|
||||
fakeClient := fake.NewClientBuilder().
|
||||
WithScheme(scheme).
|
||||
Build()
|
||||
|
||||
cfg := config.NewDefault()
|
||||
selector, _ := labels.Parse("env=production")
|
||||
cfg.NamespaceSelectors = []labels.Selector{selector}
|
||||
|
||||
cache := controller.NewNamespaceCache(true)
|
||||
// Pre-populate cache
|
||||
cache.Add("deleted-ns")
|
||||
|
||||
reconciler := &controller.NamespaceReconciler{
|
||||
Client: fakeClient,
|
||||
Log: testr.New(t),
|
||||
Config: cfg,
|
||||
Cache: cache,
|
||||
}
|
||||
|
||||
req := ctrl.Request{
|
||||
NamespacedName: types.NamespacedName{Name: "deleted-ns"},
|
||||
}
|
||||
|
||||
_, err := reconciler.Reconcile(context.Background(), req)
|
||||
if err != nil {
|
||||
t.Fatalf("Reconcile failed: %v", err)
|
||||
}
|
||||
|
||||
if cache.Contains("deleted-ns") {
|
||||
t.Error("Cache should not contain deleted-ns after reconcile")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNamespaceReconciler_MultipleSelectors(t *testing.T) {
|
||||
scheme := runtime.NewScheme()
|
||||
_ = corev1.AddToScheme(scheme)
|
||||
|
||||
ns := &corev1.Namespace{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-ns",
|
||||
Labels: map[string]string{"team": "platform"},
|
||||
},
|
||||
}
|
||||
|
||||
fakeClient := fake.NewClientBuilder().
|
||||
WithScheme(scheme).
|
||||
WithObjects(ns).
|
||||
Build()
|
||||
|
||||
cfg := config.NewDefault()
|
||||
selector1, _ := labels.Parse("env=production")
|
||||
selector2, _ := labels.Parse("team=platform")
|
||||
cfg.NamespaceSelectors = []labels.Selector{selector1, selector2}
|
||||
|
||||
cache := controller.NewNamespaceCache(true)
|
||||
reconciler := &controller.NamespaceReconciler{
|
||||
Client: fakeClient,
|
||||
Log: testr.New(t),
|
||||
Config: cfg,
|
||||
Cache: cache,
|
||||
}
|
||||
|
||||
req := ctrl.Request{
|
||||
NamespacedName: types.NamespacedName{Name: "test-ns"},
|
||||
}
|
||||
|
||||
_, err := reconciler.Reconcile(context.Background(), req)
|
||||
if err != nil {
|
||||
t.Fatalf("Reconcile failed: %v", err)
|
||||
}
|
||||
|
||||
// Should be added because it matches second selector (team=platform)
|
||||
if !cache.Contains("test-ns") {
|
||||
t.Error("Cache should contain test-ns (matches second selector)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNamespaceReconciler_NoLabels(t *testing.T) {
|
||||
scheme := runtime.NewScheme()
|
||||
_ = corev1.AddToScheme(scheme)
|
||||
|
||||
// Namespace with no labels
|
||||
ns := &corev1.Namespace{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-ns",
|
||||
},
|
||||
}
|
||||
|
||||
fakeClient := fake.NewClientBuilder().
|
||||
WithScheme(scheme).
|
||||
WithObjects(ns).
|
||||
Build()
|
||||
|
||||
cfg := config.NewDefault()
|
||||
selector, _ := labels.Parse("env=production")
|
||||
cfg.NamespaceSelectors = []labels.Selector{selector}
|
||||
|
||||
cache := controller.NewNamespaceCache(true)
|
||||
reconciler := &controller.NamespaceReconciler{
|
||||
Client: fakeClient,
|
||||
Log: testr.New(t),
|
||||
Config: cfg,
|
||||
Cache: cache,
|
||||
}
|
||||
|
||||
req := ctrl.Request{
|
||||
NamespacedName: types.NamespacedName{Name: "test-ns"},
|
||||
}
|
||||
|
||||
_, err := reconciler.Reconcile(context.Background(), req)
|
||||
if err != nil {
|
||||
t.Fatalf("Reconcile failed: %v", err)
|
||||
}
|
||||
|
||||
if cache.Contains("test-ns") {
|
||||
t.Error("Cache should not contain test-ns (no labels)")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user