feat: Implement NamespaceReconciler for namespace label selector filtering

This commit is contained in:
TheiLLeniumStudios
2025-12-28 08:47:53 +01:00
parent b5df945fff
commit 3cb45e8dc7
6 changed files with 1270 additions and 3 deletions
@@ -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)")
}
}