From e8cf48e52ae1d29fcb5a82a0a63c0eb0c97235a6 Mon Sep 17 00:00:00 2001 From: Chayan Das <01chayandas@gmail.com> Date: Mon, 2 Feb 2026 18:26:24 +0530 Subject: [PATCH 1/5] backend: k8scache: add stopwatcher to remove the context from watch registry --- backend/cmd/server.go | 4 +++- backend/pkg/k8cache/cacheInvalidation.go | 9 +++++++++ backend/pkg/kubeconfig/watcher.go | 6 ++++++ 3 files changed, 18 insertions(+), 1 deletion(-) diff --git a/backend/cmd/server.go b/backend/cmd/server.go index 23bb8ac5891..0a29873942c 100644 --- a/backend/cmd/server.go +++ b/backend/cmd/server.go @@ -183,7 +183,9 @@ func GetContextKeyAndKContext(w http.ResponseWriter, // CacheMiddleWare is Middleware for Caching purpose. It involves generating key for a request, // authorizing user , store resource data in cache and returns data if key is present. -func CacheMiddleWare(c *HeadlampConfig) mux.MiddlewareFunc { +func CacheMiddleWare(c *HeadlampConfig) mux.MiddlewareFunc { //nolint: funlen + kubeconfig.StopContextWatcher = k8cache.StopWatcher + return func(next http.Handler) http.Handler { if !c.CacheEnabled { return next diff --git a/backend/pkg/k8cache/cacheInvalidation.go b/backend/pkg/k8cache/cacheInvalidation.go index 3fba2a1c7e9..a97fbf7c9e2 100644 --- a/backend/pkg/k8cache/cacheInvalidation.go +++ b/backend/pkg/k8cache/cacheInvalidation.go @@ -161,6 +161,15 @@ func CheckForChanges( go runWatcher(ctx, k8scache, contextKey, kContext) } +// StopWatcher stops and cleans up the watcher for a given contextKey. +func StopWatcher(contextKey string) { + if cancelAny, ok := contextCancel.Load(contextKey); ok { + cancelAny.(context.CancelFunc)() + contextCancel.Delete(contextKey) + watcherRegistry.Delete(contextKey) + } +} + // runWatcher is a long-lived goroutine that sets up and runs Kubernetes informers. // It watches for resource changes and invalidates corresponding cache entries. // This function will only exit when its context is cancelled. diff --git a/backend/pkg/kubeconfig/watcher.go b/backend/pkg/kubeconfig/watcher.go index adb31cd6ab9..cf75784cda4 100644 --- a/backend/pkg/kubeconfig/watcher.go +++ b/backend/pkg/kubeconfig/watcher.go @@ -13,6 +13,8 @@ import ( const watchInterval = 10 * time.Second +var StopContextWatcher func(contextName string) + // LoadAndWatchFiles loads kubeconfig files and watches them for changes. func LoadAndWatchFiles(kubeConfigStore ContextStore, paths string, source int, ignoreFunc shouldBeSkippedFunc) { // create ticker @@ -138,6 +140,10 @@ func syncContexts(kubeConfigStore ContextStore, paths string, source int, ignore } if !found { + if StopContextWatcher != nil { + StopContextWatcher(existingCtx.Name) + } + err := kubeConfigStore.RemoveContext(existingCtx.Name) if err != nil { logger.Log(logger.LevelError, nil, err, "error removing context") From 09dade50ff961d7a01fde022f4d06fd4bf2192e3 Mon Sep 17 00:00:00 2001 From: Chayan Das <01chayandas@gmail.com> Date: Mon, 2 Feb 2026 22:28:21 +0530 Subject: [PATCH 2/5] backend: k8scache: add logs --- backend/cmd/headlamp.go | 3 +++ backend/cmd/server.go | 4 +--- backend/pkg/k8cache/cacheInvalidation.go | 1 + 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/backend/cmd/headlamp.go b/backend/cmd/headlamp.go index b428cc4dd52..06f996343f7 100644 --- a/backend/cmd/headlamp.go +++ b/backend/cmd/headlamp.go @@ -47,6 +47,7 @@ import ( auth "github.com/kubernetes-sigs/headlamp/backend/pkg/auth" "github.com/kubernetes-sigs/headlamp/backend/pkg/cache" cfg "github.com/kubernetes-sigs/headlamp/backend/pkg/config" + "github.com/kubernetes-sigs/headlamp/backend/pkg/k8cache" "github.com/kubernetes-sigs/headlamp/backend/pkg/serviceproxy" headlampcfg "github.com/kubernetes-sigs/headlamp/backend/pkg/headlampconfig" @@ -444,6 +445,8 @@ func createHeadlampHandler(config *HeadlampConfig) http.Handler { pluginEventChan, config.cache, ) + // Set the function to stop kubeconfig context watchers + kubeconfig.StopContextWatcher = k8cache.StopWatcher // in-cluster mode is unlikely to want reloading kubeconfig. go kubeconfig.LoadAndWatchFiles(config.KubeConfigStore, kubeConfigPath, kubeconfig.KubeConfig, skipFunc) } diff --git a/backend/cmd/server.go b/backend/cmd/server.go index 0a29873942c..23bb8ac5891 100644 --- a/backend/cmd/server.go +++ b/backend/cmd/server.go @@ -183,9 +183,7 @@ func GetContextKeyAndKContext(w http.ResponseWriter, // CacheMiddleWare is Middleware for Caching purpose. It involves generating key for a request, // authorizing user , store resource data in cache and returns data if key is present. -func CacheMiddleWare(c *HeadlampConfig) mux.MiddlewareFunc { //nolint: funlen - kubeconfig.StopContextWatcher = k8cache.StopWatcher - +func CacheMiddleWare(c *HeadlampConfig) mux.MiddlewareFunc { return func(next http.Handler) http.Handler { if !c.CacheEnabled { return next diff --git a/backend/pkg/k8cache/cacheInvalidation.go b/backend/pkg/k8cache/cacheInvalidation.go index a97fbf7c9e2..9a51cc01365 100644 --- a/backend/pkg/k8cache/cacheInvalidation.go +++ b/backend/pkg/k8cache/cacheInvalidation.go @@ -163,6 +163,7 @@ func CheckForChanges( // StopWatcher stops and cleans up the watcher for a given contextKey. func StopWatcher(contextKey string) { + logger.Log(logger.LevelInfo, nil, nil, "stopping watcher for context: "+contextKey) if cancelAny, ok := contextCancel.Load(contextKey); ok { cancelAny.(context.CancelFunc)() contextCancel.Delete(contextKey) From 31e10c4765513b88019512ce94e7d589bd5eb7a0 Mon Sep 17 00:00:00 2001 From: Chayan Das <01chayandas@gmail.com> Date: Mon, 2 Feb 2026 22:36:57 +0530 Subject: [PATCH 3/5] backend: k8scache: fix lint --- backend/pkg/k8cache/cacheInvalidation.go | 1 + 1 file changed, 1 insertion(+) diff --git a/backend/pkg/k8cache/cacheInvalidation.go b/backend/pkg/k8cache/cacheInvalidation.go index 9a51cc01365..2aca23cc7b7 100644 --- a/backend/pkg/k8cache/cacheInvalidation.go +++ b/backend/pkg/k8cache/cacheInvalidation.go @@ -164,6 +164,7 @@ func CheckForChanges( // StopWatcher stops and cleans up the watcher for a given contextKey. func StopWatcher(contextKey string) { logger.Log(logger.LevelInfo, nil, nil, "stopping watcher for context: "+contextKey) + if cancelAny, ok := contextCancel.Load(contextKey); ok { cancelAny.(context.CancelFunc)() contextCancel.Delete(contextKey) From 7fce783449706ea9864853b35653ab46966b5b7e Mon Sep 17 00:00:00 2001 From: Chayan Das <01chayandas@gmail.com> Date: Tue, 3 Feb 2026 01:12:49 +0530 Subject: [PATCH 4/5] backend: k8scache: add test --- backend/cmd/headlamp_test.go | 39 ++++++++++++ backend/pkg/kubeconfig/watcher_test.go | 83 ++++++++++++++++++++++++++ 2 files changed, 122 insertions(+) diff --git a/backend/cmd/headlamp_test.go b/backend/cmd/headlamp_test.go index c67563d74b8..3268c2bb97f 100644 --- a/backend/cmd/headlamp_test.go +++ b/backend/cmd/headlamp_test.go @@ -31,6 +31,7 @@ import ( "net/url" "os" "path/filepath" + "reflect" "strconv" "strings" "testing" @@ -41,6 +42,7 @@ import ( "github.com/kubernetes-sigs/headlamp/backend/pkg/cache" "github.com/kubernetes-sigs/headlamp/backend/pkg/config" "github.com/kubernetes-sigs/headlamp/backend/pkg/headlampconfig" + "github.com/kubernetes-sigs/headlamp/backend/pkg/k8cache" "github.com/kubernetes-sigs/headlamp/backend/pkg/kubeconfig" "github.com/kubernetes-sigs/headlamp/backend/pkg/telemetry" "github.com/stretchr/testify/assert" @@ -322,6 +324,43 @@ func TestDynamicClustersKubeConfig(t *testing.T) { assert.Equal(t, "default", minikubeCluster.Metadata["namespace"]) } +func TestWiresStopContextWatcher(t *testing.T) { + orig := kubeconfig.StopContextWatcher + defer func() { + kubeconfig.StopContextWatcher = orig + }() + kubeconfig.StopContextWatcher = nil + cache := cache.New[interface{}]() + kubeConfigStore := kubeconfig.NewContextStore() + + c := HeadlampConfig{ + HeadlampCFG: &headlampconfig.HeadlampCFG{ + UseInCluster: false, + KubeConfigPath: "", + EnableDynamicClusters: true, + KubeConfigStore: kubeConfigStore, + }, + cache: cache, + telemetryConfig: GetDefaultTestTelemetryConfig(), + telemetryHandler: &telemetry.RequestHandler{}, + } + + handler := createHeadlampHandler(&c) + require.NotNil(t, handler) + + require.NotNil( + t, + kubeconfig.StopContextWatcher, + "StopContextWatcher should be wired during handler creation", + ) + require.Equal( + t, + reflect.ValueOf(k8cache.StopWatcher).Pointer(), + reflect.ValueOf(kubeconfig.StopContextWatcher).Pointer(), + "StopContextWatcher should be wired to k8cache.StopWatcher", + ) +} + func TestInvalidKubeConfig(t *testing.T) { cache := cache.New[interface{}]() kubeConfigStore := kubeconfig.NewContextStore() diff --git a/backend/pkg/kubeconfig/watcher_test.go b/backend/pkg/kubeconfig/watcher_test.go index e31b6279024..4bef7d36cb3 100644 --- a/backend/pkg/kubeconfig/watcher_test.go +++ b/backend/pkg/kubeconfig/watcher_test.go @@ -3,6 +3,7 @@ package kubeconfig_test import ( "runtime" "strings" + "sync" "testing" "time" @@ -95,7 +96,89 @@ func TestWatchAndLoadFiles(t *testing.T) { require.True(t, removed, "Context should have been removed") }) + t.Run("StopContextWatcher callback", func(t *testing.T) { + // Sleep to ensure watcher is ready + time.Sleep(2 * time.Second) + + stoppedContexts := make(map[string]int) + var mu sync.Mutex + callbackInvoked := make(chan string, 10) + + kubeconfig.StopContextWatcher = func(contextName string) { + mu.Lock() + stoppedContexts[contextName]++ + count := stoppedContexts[contextName] + mu.Unlock() + select { + case callbackInvoked <- contextName: + default: + } + + t.Logf("StopContextWatcher called for context: %s (count: %d)", contextName, count) + } + defer func() { + kubeconfig.StopContextWatcher = nil + }() + + config, err := clientcmd.LoadFromFile("./test_data/kubeconfig1") + require.NoError(t, err) + + testContextName := "test-context-for-callback" + config.Contexts[testContextName] = &clientcmdapi.Context{ + Cluster: "docker-desktop", + AuthInfo: "docker-desktop", + } + err = clientcmd.WriteToFile(*config, "./test_data/kubeconfig1") + require.NoError(t, err) + + // Wait for context to be added + found := false + for i := 0; i < 20; i++ { + context, err := kubeConfigStore.GetContext(testContextName) + if err == nil && context != nil { + found = true + t.Logf("Context %s found in store after %d attempts", testContextName, i+1) + break + } + time.Sleep(500 * time.Millisecond) + } + require.True(t, found, "Context should have been added") + + time.Sleep(1 * time.Second) + + for len(callbackInvoked) > 0 { + <-callbackInvoked + } + + t.Logf("Removing context %s from kubeconfig file", testContextName) + + config, err = clientcmd.LoadFromFile("./test_data/kubeconfig1") + require.NoError(t, err) + delete(config.Contexts, testContextName) + + err = clientcmd.WriteToFile(*config, "./test_data/kubeconfig1") + require.NoError(t, err) + + var removedContext string + select { + case removedContext = <-callbackInvoked: + t.Logf("Received callback for context: %s", removedContext) + case <-time.After(10 * time.Second): + t.Fatal("Timeout waiting for StopContextWatcher callback") + } + + require.Equal(t, testContextName, removedContext, "StopContextWatcher should have been called for the removed context") + + _, err = kubeConfigStore.GetContext(testContextName) + require.Error(t, err, "Context should have been removed from store") + + mu.Lock() + callCount := stoppedContexts[testContextName] + mu.Unlock() + t.Logf("StopContextWatcher was called %d time(s) for %s", callCount, testContextName) + require.Greater(t, callCount, 0, "StopContextWatcher should have been called at least once") + }) // Cleanup in case test fails defer func() { config, err := clientcmd.LoadFromFile("./test_data/kubeconfig1") From 6a551d81d1a0f18dbfe7324037d6b8a48ec22dca Mon Sep 17 00:00:00 2001 From: Chayan Das <01chayandas@gmail.com> Date: Tue, 3 Feb 2026 01:22:18 +0530 Subject: [PATCH 5/5] backend: k8scache: add linting --- backend/cmd/headlamp_test.go | 1 + backend/pkg/kubeconfig/watcher_test.go | 10 +++++++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/backend/cmd/headlamp_test.go b/backend/cmd/headlamp_test.go index 3268c2bb97f..201ac3ea293 100644 --- a/backend/cmd/headlamp_test.go +++ b/backend/cmd/headlamp_test.go @@ -329,6 +329,7 @@ func TestWiresStopContextWatcher(t *testing.T) { defer func() { kubeconfig.StopContextWatcher = orig }() + kubeconfig.StopContextWatcher = nil cache := cache.New[interface{}]() kubeConfigStore := kubeconfig.NewContextStore() diff --git a/backend/pkg/kubeconfig/watcher_test.go b/backend/pkg/kubeconfig/watcher_test.go index 4bef7d36cb3..72cb75cf28f 100644 --- a/backend/pkg/kubeconfig/watcher_test.go +++ b/backend/pkg/kubeconfig/watcher_test.go @@ -101,7 +101,9 @@ func TestWatchAndLoadFiles(t *testing.T) { time.Sleep(2 * time.Second) stoppedContexts := make(map[string]int) + var mu sync.Mutex + callbackInvoked := make(chan string, 10) kubeconfig.StopContextWatcher = func(contextName string) { @@ -134,15 +136,20 @@ func TestWatchAndLoadFiles(t *testing.T) { // Wait for context to be added found := false + for i := 0; i < 20; i++ { context, err := kubeConfigStore.GetContext(testContextName) if err == nil && context != nil { found = true + t.Logf("Context %s found in store after %d attempts", testContextName, i+1) + break } + time.Sleep(500 * time.Millisecond) } + require.True(t, found, "Context should have been added") time.Sleep(1 * time.Second) @@ -168,7 +175,8 @@ func TestWatchAndLoadFiles(t *testing.T) { t.Fatal("Timeout waiting for StopContextWatcher callback") } - require.Equal(t, testContextName, removedContext, "StopContextWatcher should have been called for the removed context") + require.Equal(t, testContextName, + removedContext, "StopContextWatcher should have been called for the removed context") _, err = kubeConfigStore.GetContext(testContextName) require.Error(t, err, "Context should have been removed from store")