Skip to content

Commit 30e832a

Browse files
authored
Restart health checks when a backend's auth config changes (#6510)
backendChanged compared only BaseURL and TransportType, so UpdateBackends never restarted a check goroutine when a backend gained or changed outgoing auth. The stale auth-less copy kept probing tokenless, the 401 classified BackendUnauthenticated instead of the expected-auth-challenge healthy, and ShouldAdvertise dropped the backend from aggregation until a pod restart. Comparing AuthConfig in backendChanged restarts the loop so probes classify against the current auth config. Includes the red-green regression test plus backendChanged table cases.
1 parent fd1e7b5 commit 30e832a

2 files changed

Lines changed: 98 additions & 2 deletions

File tree

pkg/vmcp/health/monitor.go

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"errors"
99
"fmt"
1010
"log/slog"
11+
"reflect"
1112
"sync"
1213
"time"
1314

@@ -1040,7 +1041,14 @@ func buildConditions(summary Summary, phase vmcp.Phase, configuredBackendCount i
10401041

10411042
// backendChanged returns true if the backend's health-check-relevant properties have changed.
10421043
// This is used by UpdateBackends to detect when an existing backend needs its monitoring
1043-
// goroutine restarted (e.g., URL updated after operator reconcile).
1044+
// goroutine restarted (e.g., URL updated after operator reconcile, or outgoing auth
1045+
// added, removed, or changed). AuthConfig matters because probe classification depends
1046+
// on it: a 401 from an auth-configured backend is the expected answer to a
1047+
// credential-less probe and counts as healthy, while the same 401 without an auth
1048+
// config means misconfiguration — so a check loop probing with a stale copy inverts
1049+
// the classification and the backend is dropped from aggregation (#6509).
10441050
func backendChanged(old, updated vmcp.Backend) bool {
1045-
return old.BaseURL != updated.BaseURL || old.TransportType != updated.TransportType
1051+
return old.BaseURL != updated.BaseURL ||
1052+
old.TransportType != updated.TransportType ||
1053+
!reflect.DeepEqual(old.AuthConfig, updated.AuthConfig)
10461054
}

pkg/vmcp/health/monitor_test.go

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import (
1515

1616
mcpparser "github.com/stacklok/toolhive/pkg/mcp"
1717
"github.com/stacklok/toolhive/pkg/vmcp"
18+
authtypes "github.com/stacklok/toolhive/pkg/vmcp/auth/types"
1819
"github.com/stacklok/toolhive/pkg/vmcp/mocks"
1920
)
2021

@@ -915,6 +916,36 @@ func TestBackendChanged(t *testing.T) {
915916
new: vmcp.Backend{BaseURL: "http://new-svc:9090", TransportType: "streamable-http"},
916917
expected: true,
917918
},
919+
{
920+
name: "auth config added",
921+
old: vmcp.Backend{BaseURL: "http://svc:8080", TransportType: "sse"},
922+
new: vmcp.Backend{BaseURL: "http://svc:8080", TransportType: "sse",
923+
AuthConfig: &authtypes.BackendAuthStrategy{Type: authtypes.StrategyTypeUpstreamInject}},
924+
expected: true,
925+
},
926+
{
927+
name: "auth config removed",
928+
old: vmcp.Backend{BaseURL: "http://svc:8080", TransportType: "sse",
929+
AuthConfig: &authtypes.BackendAuthStrategy{Type: authtypes.StrategyTypeUpstreamInject}},
930+
new: vmcp.Backend{BaseURL: "http://svc:8080", TransportType: "sse"},
931+
expected: true,
932+
},
933+
{
934+
name: "auth config type changed",
935+
old: vmcp.Backend{BaseURL: "http://svc:8080", TransportType: "sse",
936+
AuthConfig: &authtypes.BackendAuthStrategy{Type: authtypes.StrategyTypeUpstreamInject}},
937+
new: vmcp.Backend{BaseURL: "http://svc:8080", TransportType: "sse",
938+
AuthConfig: &authtypes.BackendAuthStrategy{Type: authtypes.StrategyTypeTokenExchange}},
939+
expected: true,
940+
},
941+
{
942+
name: "auth config equal",
943+
old: vmcp.Backend{BaseURL: "http://svc:8080", TransportType: "sse",
944+
AuthConfig: &authtypes.BackendAuthStrategy{Type: authtypes.StrategyTypeUpstreamInject}},
945+
new: vmcp.Backend{BaseURL: "http://svc:8080", TransportType: "sse",
946+
AuthConfig: &authtypes.BackendAuthStrategy{Type: authtypes.StrategyTypeUpstreamInject}},
947+
expected: false,
948+
},
918949
}
919950

920951
for _, tt := range tests {
@@ -987,6 +1018,63 @@ func TestMonitor_UpdateBackends_PropertyChange(t *testing.T) {
9871018
assert.Equal(t, 1, summary.Healthy, "backend should be healthy")
9881019
}
9891020

1021+
// TestMonitor_UpdateBackends_AuthConfigChange is the regression test for #6509:
1022+
// a backend that gains outgoing auth after the monitor started must have its
1023+
// check goroutine restarted, so the 401 from its credential-less probe is
1024+
// classified against the current auth config (the expected auth challenge,
1025+
// healthy) instead of the stale auth-less copy (unauthenticated).
1026+
func TestMonitor_UpdateBackends_AuthConfigChange(t *testing.T) {
1027+
t.Parallel()
1028+
1029+
ctrl := gomock.NewController(t)
1030+
t.Cleanup(ctrl.Finish)
1031+
1032+
mockClient := mocks.NewMockBackendClient(ctrl)
1033+
1034+
// The backend enforces auth on its MCP endpoint: every probe fails with an
1035+
// authentication error, because health probes deliberately carry no credentials.
1036+
mockClient.EXPECT().
1037+
ListCapabilities(gomock.Any(), gomock.Any()).
1038+
Return(nil, errors.Join(vmcp.ErrAuthenticationFailed, errors.New("initialize: 401"))).
1039+
AnyTimes()
1040+
1041+
initialBackends := []vmcp.Backend{
1042+
{ID: "backend-1", Name: "Backend 1", BaseURL: "http://svc:8080", TransportType: "sse"},
1043+
}
1044+
1045+
config := MonitorConfig{
1046+
CheckInterval: 50 * time.Millisecond,
1047+
UnhealthyThreshold: 1,
1048+
Timeout: 10 * time.Millisecond,
1049+
}
1050+
1051+
monitor, err := NewMonitor(mockClient, initialBackends, config)
1052+
require.NoError(t, err)
1053+
1054+
ctx := context.Background()
1055+
require.NoError(t, monitor.Start(ctx))
1056+
t.Cleanup(func() { _ = monitor.Stop() })
1057+
1058+
// Without an auth config the 401 means misconfiguration: unauthenticated.
1059+
require.Eventually(t, func() bool {
1060+
status, ok := monitor.QueryBackendStatus("backend-1")
1061+
return ok && status == vmcp.BackendUnauthenticated
1062+
}, 500*time.Millisecond, 10*time.Millisecond, "backend-1 should be classified unauthenticated while auth-less")
1063+
1064+
// The backend gains outgoing auth (e.g. an externalAuthConfigRef reconciled
1065+
// into the registry). URL and transport are unchanged.
1066+
monitor.UpdateBackends([]vmcp.Backend{
1067+
{ID: "backend-1", Name: "Backend 1", BaseURL: "http://svc:8080", TransportType: "sse",
1068+
AuthConfig: &authtypes.BackendAuthStrategy{Type: authtypes.StrategyTypeUpstreamInject}},
1069+
})
1070+
1071+
// The restarted check loop probes with the auth-carrying copy: the same 401
1072+
// is now the expected auth challenge and classifies healthy.
1073+
require.Eventually(t, func() bool {
1074+
return monitor.IsBackendHealthy("backend-1")
1075+
}, 500*time.Millisecond, 10*time.Millisecond, "backend-1 should classify healthy once its auth config reaches the check loop")
1076+
}
1077+
9901078
func TestMonitor_CircuitBreakerDisabled(t *testing.T) {
9911079
t.Parallel()
9921080

0 commit comments

Comments
 (0)