Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion cmd/thv-operator/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,13 @@ func Run() {
},
}

mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), options)
cfg := ctrl.GetConfigOrDie()
// The MCPRegistry CRD is deprecated; kube-apiserver emits a 299 Warning
// header on every LIST/WATCH. Without this, the cache logs it on every
// informer resync (~6m) even when no MCPRegistry CRs exist (#6346).
installMCPRegistryWarningHandler(cfg)

mgr, err := ctrl.NewManager(cfg, options)
if err != nil {
setupLog.Error(err, "unable to start manager")
os.Exit(1)
Expand Down
66 changes: 66 additions & 0 deletions cmd/thv-operator/app/warning_handler.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
// SPDX-License-Identifier: Apache-2.0

package app

import (
"context"
"strings"
"sync"

"k8s.io/client-go/rest"
"sigs.k8s.io/controller-runtime/pkg/log"
)

// mcpRegistryDeprecationWarningMarker is the unique prefix of the Warning
// header kube-apiserver sends because the MCPRegistry CRD is marked
// +kubebuilder:deprecatedversion. The controller-runtime cache LIST/resync
// path surfaces this header even when zero MCPRegistry CRs exist
// (https://github.com/stacklok/toolhive/issues/6346).
const mcpRegistryDeprecationWarningMarker = "MCPRegistry is deprecated"

// mcpRegistryOnceWarningHandler logs the MCPRegistry CRD deprecation at most
// once (first cache LIST / controller startup) and forwards every other API
// warning to next unchanged.
type mcpRegistryOnceWarningHandler struct {
once sync.Once
next rest.WarningHandlerWithContext
}

var (
_ rest.WarningHandler = (*mcpRegistryOnceWarningHandler)(nil)
_ rest.WarningHandlerWithContext = (*mcpRegistryOnceWarningHandler)(nil)
)

func newMCPRegistryOnceWarningHandler(next rest.WarningHandlerWithContext) *mcpRegistryOnceWarningHandler {
if next == nil {
next = log.NewKubeAPIWarningLogger(log.KubeAPIWarningLoggerOptions{})
}
return &mcpRegistryOnceWarningHandler{next: next}
}

func (h *mcpRegistryOnceWarningHandler) HandleWarningHeader(code int, agent, text string) {
h.HandleWarningHeaderWithContext(context.Background(), code, agent, text)
}

func (h *mcpRegistryOnceWarningHandler) HandleWarningHeaderWithContext(
ctx context.Context, code int, agent, text string,
) {
if code == 299 && strings.Contains(text, mcpRegistryDeprecationWarningMarker) {
h.once.Do(func() {
h.next.HandleWarningHeaderWithContext(ctx, code, agent, text)
})
return
}
h.next.HandleWarningHeaderWithContext(ctx, code, agent, text)
}

// installMCPRegistryWarningHandler attaches a once-only handler for the
// MCPRegistry CRD deprecation so the cache informer cannot spam it on every
// resync. Other API warnings keep the default controller-runtime logger.
func installMCPRegistryWarningHandler(cfg *rest.Config) {
h := newMCPRegistryOnceWarningHandler(rest.WarningLogger{})
cfg.WarningHandler = h
cfg.WarningHandlerWithContext = h
rest.SetDefaultWarningHandlerWithContext(h)
}
139 changes: 139 additions & 0 deletions cmd/thv-operator/app/warning_handler_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
// SPDX-License-Identifier: Apache-2.0

package app

import (
"context"
"sync"
"testing"
"time"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"k8s.io/client-go/rest"
)

// mcpRegistryDeprecationWarningText is the exact Warning header kube-apiserver
// emits for the MCPRegistry +kubebuilder:deprecatedversion marker. Matches
// issue https://github.com/stacklok/toolhive/issues/6346.
const mcpRegistryDeprecationWarningText = "MCPRegistry is deprecated and will be removed in a future release; " +
"install the ToolHive registry server via the toolhive-registry-server Helm chart " +
"(https://github.com/stacklok/toolhive-registry-server) instead"

// recordingWarningHandler records every warning the operator handler forwards,
// standing in for the controller-runtime/cache logger.
type recordingWarningHandler struct {
mu sync.Mutex
messages []string
}

func (h *recordingWarningHandler) HandleWarningHeaderWithContext(
_ context.Context, _ int, _ string, text string,
) {
h.mu.Lock()
defer h.mu.Unlock()
h.messages = append(h.messages, text)
}

func (h *recordingWarningHandler) snapshot() []string {
h.mu.Lock()
defer h.mu.Unlock()
out := make([]string, len(h.messages))
copy(out, h.messages)
return out
}

var _ rest.WarningHandlerWithContext = (*recordingWarningHandler)(nil)

// TestMCPRegistryDeprecationWarningLoggedOnceAcrossResyncs reproduces #6346:
// the cache LIST path delivers the MCPRegistry deprecation Warning header on
// every resync even when no MCPRegistry CRs exist. The operator must surface
// that warning at most once.
func TestMCPRegistryDeprecationWarningLoggedOnceAcrossResyncs(t *testing.T) {
t.Parallel()

recorder := &recordingWarningHandler{}
handler := newMCPRegistryOnceWarningHandler(recorder)

// Two cache resyncs / two warning-path invocations, no CRs involved.
handler.HandleWarningHeaderWithContext(t.Context(), 299, "kube-apiserver", mcpRegistryDeprecationWarningText)
handler.HandleWarningHeaderWithContext(t.Context(), 299, "kube-apiserver", mcpRegistryDeprecationWarningText)

got := recorder.snapshot()
require.NotEmpty(t, got, "the first cache LIST must still surface the deprecation warning")
assert.Len(t, got, 1,
"MCPRegistry deprecation warning must be logged once, not on every cache resync; got %d: %v",
len(got), got)
assert.Contains(t, got[0], mcpRegistryDeprecationWarningMarker)
}

func TestMCPRegistryOnceWarningHandler_otherWarningsPassThrough(t *testing.T) {
t.Parallel()

recorder := &recordingWarningHandler{}
handler := newMCPRegistryOnceWarningHandler(recorder)

other := "toolhive.stacklok.dev/v1alpha1 is deprecated; use v1beta1"
handler.HandleWarningHeaderWithContext(t.Context(), 299, "kube-apiserver", other)
handler.HandleWarningHeaderWithContext(t.Context(), 299, "kube-apiserver", other)
handler.HandleWarningHeaderWithContext(t.Context(), 299, "kube-apiserver", mcpRegistryDeprecationWarningText)
handler.HandleWarningHeaderWithContext(t.Context(), 299, "kube-apiserver", mcpRegistryDeprecationWarningText)
handler.HandleWarningHeaderWithContext(t.Context(), 299, "kube-apiserver", other)

got := recorder.snapshot()
require.Equal(t, []string{other, other, mcpRegistryDeprecationWarningText, other}, got)
}

func TestMCPRegistryOnceWarningHandler_concurrentResyncsLogOnce(t *testing.T) {
t.Parallel()

recorder := &recordingWarningHandler{}
handler := newMCPRegistryOnceWarningHandler(recorder)

const workers = 16
var wg sync.WaitGroup
wg.Add(workers)
for i := 0; i < workers; i++ {
go func() {
defer wg.Done()
handler.HandleWarningHeaderWithContext(t.Context(), 299, "kube-apiserver", mcpRegistryDeprecationWarningText)
}()
}

done := make(chan struct{})
go func() { wg.Wait(); close(done) }()
select {
case <-done:
case <-time.After(5 * time.Second):
t.Fatal("timeout waiting for concurrent warning-handler goroutines")
}

assert.Len(t, recorder.snapshot(), 1)
}

func TestInstallMCPRegistryWarningHandler(t *testing.T) {
// Mutates the process-wide client-go default warning handler.
t.Cleanup(func() {
rest.SetDefaultWarningHandlerWithContext(rest.WarningLogger{})
})

cfg := &rest.Config{}
installMCPRegistryWarningHandler(cfg)

require.NotNil(t, cfg.WarningHandler)
require.NotNil(t, cfg.WarningHandlerWithContext)

recorder := &recordingWarningHandler{}
h, ok := cfg.WarningHandlerWithContext.(*mcpRegistryOnceWarningHandler)
require.True(t, ok)
h.next = recorder

legacy, ok := cfg.WarningHandler.(rest.WarningHandler)
require.True(t, ok)
legacy.HandleWarningHeader(299, "kube-apiserver", mcpRegistryDeprecationWarningText)
cfg.WarningHandlerWithContext.HandleWarningHeaderWithContext(
t.Context(), 299, "kube-apiserver", mcpRegistryDeprecationWarningText)

require.Len(t, recorder.snapshot(), 1)
}