Skip to content

Commit c9a5402

Browse files
reyortiz3claude
andauthored
Fix stale config in registry routes and WorkloadService (#4751)
* Fix stale config in registry routes and WorkloadService Two independent config snapshots caused inconsistency after enterprise registry changes: 1. NewRegistryRoutes/NewRegistryRoutesForServe created separate config.Provider instances for configProvider and configService. This meant getRegistry responses mixed live metadata (from configService) with stale server lists (from the old independent provider). Fix: create a single provider p and pass it to both. 2. WorkloadService.appConfig was a one-time snapshot taken at construction. After an enterprise registry change, new workloads got stale registry URLs written into their persisted RunConfig. Fix: store config.Provider and call GetConfig() at the point of use in BuildFullRunConfig. Add tests verifying: - configService and getCurrentProvider share the same provider instance (listRegistries returns consistent type/source and server count) - WorkloadService.configProvider is initialized at construction Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Address bot review: fix factory bypass and TOCTOU in WorkloadService - Replace config.NewDefaultProvider() with config.NewProvider() in NewWorkloadService so a registered ProviderFactory is respected, consistent with the analogous fix in NewRegistryRoutes - Hoist cfg := s.configProvider.GetConfig() once at the top of the relevant block in BuildFullRunConfig so both uses (ResolveRegistrySourceURLs and DisableUsageMetrics) see the same config snapshot within a single call - Add TestNewWorkloadService_RespectsRegisteredFactory to verify the factory-backed provider is actually used Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent a1aad30 commit c9a5402

5 files changed

Lines changed: 144 additions & 21 deletions

File tree

pkg/api/v1/registry.go

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -285,9 +285,10 @@ type RegistryRoutes struct {
285285

286286
// NewRegistryRoutes creates a new RegistryRoutes with the default config provider
287287
func NewRegistryRoutes() *RegistryRoutes {
288+
p := config.NewProvider()
288289
return &RegistryRoutes{
289-
configProvider: config.NewProvider(),
290-
configService: regpkg.NewConfigurator(),
290+
configProvider: p,
291+
configService: regpkg.NewConfiguratorWithProvider(p),
291292
}
292293
}
293294

@@ -303,9 +304,10 @@ func NewRegistryRoutesWithProvider(provider config.Provider) *RegistryRoutes {
303304
// NewRegistryRoutesForServe creates RegistryRoutes configured for serve mode.
304305
// In serve mode, the registry provider uses non-interactive auth (no browser OAuth).
305306
func NewRegistryRoutesForServe() *RegistryRoutes {
307+
p := config.NewProvider()
306308
return &RegistryRoutes{
307-
configProvider: config.NewProvider(),
308-
configService: regpkg.NewConfigurator(),
309+
configProvider: p,
310+
configService: regpkg.NewConfiguratorWithProvider(p),
309311
serveMode: true,
310312
}
311313
}

pkg/api/v1/registry_factory_test.go

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,3 +199,91 @@ func TestNewRegistryRoutesForServe_NoFactory_ReturnsValidRoutes(t *testing.T) {
199199
assert.NotNil(t, routes.configService, "configService must be initialised")
200200
assert.True(t, routes.serveMode, "serveMode must be true for NewRegistryRoutesForServe")
201201
}
202+
203+
// TestNewRegistryRoutes_ConfigServiceAndProviderAreConsistent verifies that
204+
// configService (which drives the type/source fields) and getCurrentProvider
205+
// (which drives the server list) both draw from the same config provider instance.
206+
// Before the fix, configService used config.NewDefaultProvider() independently,
207+
// causing type/source to reflect local config while the server list could reflect
208+
// a factory-backed config (or vice versa) — inconsistency within a single response.
209+
//
210+
//nolint:paralleltest // Mutates global state: config.registeredFactory and regpkg.defaultProviderOnce
211+
func TestNewRegistryRoutes_ConfigServiceAndProviderAreConsistent(t *testing.T) {
212+
const sentinelName = "consistency-sentinel-server"
213+
214+
configPath := writeFactorySentinelRegistry(t, sentinelName)
215+
216+
config.RegisterProviderFactory(func() config.Provider {
217+
return config.NewPathProvider(configPath)
218+
})
219+
t.Cleanup(func() {
220+
config.RegisterProviderFactory(nil)
221+
registry.ResetDefaultProvider()
222+
})
223+
224+
routes := NewRegistryRoutes()
225+
registry.ResetDefaultProvider()
226+
227+
w := httptest.NewRecorder()
228+
req := httptest.NewRequest(http.MethodGet, "/registry", nil)
229+
routes.listRegistries(w, req)
230+
231+
assert.Equal(t, http.StatusOK, w.Code, "listRegistries should return 200")
232+
233+
var body registryListResponse
234+
require.NoError(t, json.NewDecoder(w.Body).Decode(&body), "response body should be valid JSON")
235+
require.Len(t, body.Registries, 1, "should return exactly one registry")
236+
237+
reg := body.Registries[0]
238+
// configService reads Type/Source from the shared provider. On the old code,
239+
// configService used config.NewDefaultProvider() which bypassed the factory,
240+
// so Type would be "default" and Source would be "" even when a factory was set.
241+
assert.Equal(t, RegistryTypeFile, reg.Type,
242+
"Type must be 'file' — proves configService uses the factory-backed provider, not an independent one")
243+
assert.NotEmpty(t, reg.Source,
244+
"Source must be non-empty for a file registry — proves configService reads from the shared provider")
245+
246+
// getCurrentProvider also uses the shared provider, so it loads servers from the same registry.
247+
// ServerCount > 0 proves both data sources are in sync.
248+
assert.Greater(t, reg.ServerCount, 0,
249+
"ServerCount must be > 0 — proves getCurrentProvider uses the same factory-backed provider as configService")
250+
}
251+
252+
// TestNewRegistryRoutesForServe_ConfigServiceAndProviderAreConsistent is the
253+
// serve-mode equivalent of TestNewRegistryRoutes_ConfigServiceAndProviderAreConsistent.
254+
//
255+
//nolint:paralleltest // Mutates global state: config.registeredFactory and regpkg.defaultProviderOnce
256+
func TestNewRegistryRoutesForServe_ConfigServiceAndProviderAreConsistent(t *testing.T) {
257+
const sentinelName = "consistency-sentinel-server"
258+
259+
configPath := writeFactorySentinelRegistry(t, sentinelName)
260+
261+
config.RegisterProviderFactory(func() config.Provider {
262+
return config.NewPathProvider(configPath)
263+
})
264+
t.Cleanup(func() {
265+
config.RegisterProviderFactory(nil)
266+
registry.ResetDefaultProvider()
267+
})
268+
269+
routes := NewRegistryRoutesForServe()
270+
registry.ResetDefaultProvider()
271+
272+
w := httptest.NewRecorder()
273+
req := httptest.NewRequest(http.MethodGet, "/registry", nil)
274+
routes.listRegistries(w, req)
275+
276+
assert.Equal(t, http.StatusOK, w.Code, "listRegistries should return 200 in serve mode")
277+
278+
var body registryListResponse
279+
require.NoError(t, json.NewDecoder(w.Body).Decode(&body), "response body should be valid JSON")
280+
require.Len(t, body.Registries, 1, "should return exactly one registry")
281+
282+
reg := body.Registries[0]
283+
assert.Equal(t, RegistryTypeFile, reg.Type,
284+
"Type must be 'file' in serve mode — proves configService uses the factory-backed provider")
285+
assert.NotEmpty(t, reg.Source,
286+
"Source must be non-empty for a file registry in serve mode")
287+
assert.Greater(t, reg.ServerCount, 0,
288+
"ServerCount must be > 0 in serve mode — proves getCurrentProvider uses the same factory-backed provider as configService")
289+
}

pkg/api/v1/workload_service.go

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ type WorkloadService struct {
6262
debugMode bool
6363
imageRetriever retriever.Retriever
6464
imagePuller retriever.ImagePuller
65-
appConfig *config.Config
65+
configProvider config.Provider
6666
}
6767

6868
// NewWorkloadService creates a new WorkloadService instance
@@ -72,18 +72,14 @@ func NewWorkloadService(
7272
containerRuntime runtime.Runtime,
7373
debugMode bool,
7474
) *WorkloadService {
75-
// Load application config for global settings
76-
configProvider := config.NewDefaultProvider()
77-
appConfig := configProvider.GetConfig()
78-
7975
return &WorkloadService{
8076
workloadManager: workloadManager,
8177
groupManager: groupManager,
8278
containerRuntime: containerRuntime,
8379
debugMode: debugMode,
8480
imageRetriever: retriever.ResolveMCPServer,
8581
imagePuller: retriever.PullMCPServerImage,
86-
appConfig: appConfig,
82+
configProvider: config.NewProvider(),
8783
}
8884
}
8985

@@ -282,8 +278,12 @@ func (s *WorkloadService) BuildFullRunConfig(
282278
}
283279
}
284280

281+
// Snapshot config once for this request so all fields within a single BuildFullRunConfig
282+
// call are consistent with each other, even if a concurrent registry update fires mid-call.
283+
cfg := s.configProvider.GetConfig()
284+
285285
// Resolve registry source URLs and server name when the server was discovered via registry lookup.
286-
regAPIURL, regURL := runner.ResolveRegistrySourceURLs(serverMetadata, s.appConfig)
286+
regAPIURL, regURL := runner.ResolveRegistrySourceURLs(serverMetadata, cfg)
287287
regServerName := runner.ResolveRegistryServerName(serverMetadata)
288288

289289
options := []runner.RunConfigBuilderOption{
@@ -365,7 +365,7 @@ func (s *WorkloadService) BuildFullRunConfig(
365365
"",
366366
req.Name,
367367
transportType,
368-
s.appConfig.DisableUsageMetrics,
368+
cfg.DisableUsageMetrics,
369369
),
370370
)
371371

pkg/api/v1/workload_service_test.go

Lines changed: 39 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ package v1
66
import (
77
"context"
88
"errors"
9+
"os"
910
"testing"
1011

1112
"github.com/stretchr/testify/assert"
@@ -24,7 +25,7 @@ func TestWorkloadService_GetWorkloadNamesFromRequest(t *testing.T) {
2425
t.Run("with names", func(t *testing.T) {
2526
t.Parallel()
2627

27-
service := &WorkloadService{appConfig: &config.Config{}}
28+
service := &WorkloadService{configProvider: config.NewDefaultProvider()}
2829

2930
req := bulkOperationRequest{
3031
Names: []string{"workload1", "workload2"},
@@ -55,7 +56,7 @@ func TestWorkloadService_GetWorkloadNamesFromRequest(t *testing.T) {
5556
service := &WorkloadService{
5657
groupManager: mockGroupManager,
5758
workloadManager: mockWorkloadManager,
58-
appConfig: &config.Config{},
59+
configProvider: config.NewDefaultProvider(),
5960
}
6061

6162
req := bulkOperationRequest{
@@ -71,7 +72,7 @@ func TestWorkloadService_GetWorkloadNamesFromRequest(t *testing.T) {
7172
t.Run("invalid group name", func(t *testing.T) {
7273
t.Parallel()
7374

74-
service := &WorkloadService{appConfig: &config.Config{}}
75+
service := &WorkloadService{configProvider: config.NewDefaultProvider()}
7576

7677
req := bulkOperationRequest{
7778
Group: "invalid-group-name-with-special-chars!@#",
@@ -96,8 +97,8 @@ func TestWorkloadService_GetWorkloadNamesFromRequest(t *testing.T) {
9697
Return(false, nil)
9798

9899
service := &WorkloadService{
99-
groupManager: mockGroupManager,
100-
appConfig: &config.Config{},
100+
groupManager: mockGroupManager,
101+
configProvider: config.NewDefaultProvider(),
101102
}
102103

103104
req := bulkOperationRequest{
@@ -130,7 +131,7 @@ func TestWorkloadService_GetWorkloadNamesFromRequest(t *testing.T) {
130131
service := &WorkloadService{
131132
groupManager: mockGroupManager,
132133
workloadManager: mockWorkloadManager,
133-
appConfig: &config.Config{},
134+
configProvider: config.NewDefaultProvider(),
134135
}
135136

136137
req := bulkOperationRequest{
@@ -150,6 +151,38 @@ func TestNewWorkloadService(t *testing.T) {
150151

151152
service := NewWorkloadService(nil, nil, nil, false)
152153
require.NotNil(t, service)
154+
assert.NotNil(t, service.configProvider,
155+
"configProvider must be initialized so config is read fresh on each call, not snapshotted at construction")
156+
}
157+
158+
// writeFactorySentinelConfig writes a YAML config file with DisableUsageMetrics: true
159+
// as a sentinel value and returns its path.
160+
func writeFactorySentinelConfig(t *testing.T, dir string) string {
161+
t.Helper()
162+
configPath := dir + "/config.yaml"
163+
require.NoError(t, os.WriteFile(configPath, []byte("disable_usage_metrics: true\n"), 0600))
164+
return configPath
165+
}
166+
167+
// TestNewWorkloadService_RespectsRegisteredFactory verifies that NewWorkloadService
168+
// uses config.NewProvider() (which checks the registered ProviderFactory) rather than
169+
// config.NewDefaultProvider() (which always uses the default XDG path and bypasses factories).
170+
//
171+
//nolint:paralleltest // Mutates global state: config.registeredFactory
172+
func TestNewWorkloadService_RespectsRegisteredFactory(t *testing.T) {
173+
configPath := writeFactorySentinelConfig(t, t.TempDir())
174+
175+
config.RegisterProviderFactory(func() config.Provider {
176+
return config.NewPathProvider(configPath)
177+
})
178+
t.Cleanup(func() { config.RegisterProviderFactory(nil) })
179+
180+
service := NewWorkloadService(nil, nil, nil, false)
181+
require.NotNil(t, service)
182+
183+
cfg := service.configProvider.GetConfig()
184+
assert.True(t, cfg.DisableUsageMetrics,
185+
"configProvider must use the factory-backed provider — DisableUsageMetrics is the sentinel set by the factory config")
153186
}
154187

155188
func TestRuntimeConfigFromRequest(t *testing.T) {

pkg/api/v1/workloads_test.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -283,7 +283,7 @@ func TestCreateWorkload(t *testing.T) {
283283
workloadManager: mockWorkloadManager,
284284
imageRetriever: mockRetriever,
285285
imagePuller: func(_ context.Context, _ string) error { return nil },
286-
appConfig: &config.Config{},
286+
configProvider: config.NewDefaultProvider(),
287287
},
288288
}
289289

@@ -505,7 +505,7 @@ func TestUpdateWorkload(t *testing.T) {
505505
workloadManager: mockWorkloadManager,
506506
imageRetriever: mockRetriever,
507507
imagePuller: func(_ context.Context, _ string) error { return nil },
508-
appConfig: &config.Config{},
508+
configProvider: config.NewDefaultProvider(),
509509
},
510510
}
511511

@@ -651,7 +651,7 @@ func TestUpdateWorkload_PortReuse(t *testing.T) {
651651
containerRuntime: mockRuntime,
652652
imageRetriever: mockRetriever,
653653
imagePuller: func(_ context.Context, _ string) error { return nil },
654-
appConfig: &config.Config{},
654+
configProvider: config.NewDefaultProvider(),
655655
},
656656
}
657657

0 commit comments

Comments
 (0)