1package app
2
3import (
4 "context"
5 "crypto/tls"
6 "errors"
7 "net"
8 "net/http"
9 "os"
10 "path/filepath"
11 "strconv"
12 "strings"
13 "sync"
14 "testing"
15 "time"
16
17 "github.com/bnema/zerowrap"
18 "github.com/spf13/viper"
19 "github.com/stretchr/testify/assert"
20 "github.com/stretchr/testify/mock"
21 "github.com/stretchr/testify/require"
22
23 "github.com/bnema/gordon/internal/adapters/in/http/registry"
24 trafficadapter "github.com/bnema/gordon/internal/adapters/in/traffic"
25 inmocks "github.com/bnema/gordon/internal/boundaries/in/mocks"
26 outmocks "github.com/bnema/gordon/internal/boundaries/out/mocks"
27 "github.com/bnema/gordon/internal/domain"
28 cfgusecase "github.com/bnema/gordon/internal/usecase/config"
29 "github.com/bnema/gordon/internal/usecase/container"
30 "github.com/bnema/gordon/internal/usecase/proxy"
31 servicecfg "github.com/bnema/gordon/internal/usecase/services"
32 traffic "github.com/bnema/gordon/internal/usecase/traffic"
33)
34
35type watchRecorder struct {
36 called bool
37 onChange func()
38}
39
40func (w *watchRecorder) Watch(ctx context.Context, onChange func()) error {
41 w.called = true
42 w.onChange = onChange
43 return nil
44}
45
46type coordinatorRecorder struct {
47 mu sync.Mutex
48 triggerCalls int
49 applyCalls int
50 err error
51}
52
53func (c *coordinatorRecorder) Trigger(context.Context) error {
54 c.mu.Lock()
55 c.triggerCalls++
56 err := c.err
57 c.mu.Unlock()
58 return err
59}
60
61func (c *coordinatorRecorder) ApplyLoadedConfig(context.Context) error {
62 c.mu.Lock()
63 c.applyCalls++
64 err := c.err
65 c.mu.Unlock()
66 return err
67}
68
69func (c *coordinatorRecorder) TriggerCalls() int {
70 c.mu.Lock()
71 defer c.mu.Unlock()
72 return c.triggerCalls
73}
74
75func (c *coordinatorRecorder) ApplyCalls() int {
76 c.mu.Lock()
77 defer c.mu.Unlock()
78 return c.applyCalls
79}
80
81type reloadRecorder struct {
82 mu sync.Mutex
83 calls int
84 err error
85 onReload func()
86}
87
88func (r *reloadRecorder) Reload(context.Context) error {
89 r.mu.Lock()
90 r.calls++
91 err := r.err
92 onReload := r.onReload
93 r.mu.Unlock()
94
95 if onReload != nil {
96 onReload()
97 }
98
99 return err
100}
101
102func (r *reloadRecorder) Calls() int {
103 r.mu.Lock()
104 defer r.mu.Unlock()
105 return r.calls
106}
107
108type proxyRecorder struct {
109 calls int
110 config proxy.Config
111}
112
113func (p *proxyRecorder) UpdateConfig(config proxy.Config) {
114 p.calls++
115 p.config = config
116}
117
118type registryLimitsRecorder struct {
119 calls int
120 maxBlobChunkSize int64
121 maxBlobSize int64
122}
123
124func (r *registryLimitsRecorder) UpdateBlobLimits(maxBlobChunkSize, maxBlobSize int64) {
125 r.calls++
126 r.maxBlobChunkSize = maxBlobChunkSize
127 r.maxBlobSize = maxBlobSize
128}
129
130type containerConfigApplyRecorder struct {
131 calls int
132 ctx context.Context
133 cfg Config
134}
135
136func (r *containerConfigApplyRecorder) Apply(ctx context.Context, cfg Config) error {
137 r.calls++
138 r.ctx = ctx
139 r.cfg = cfg
140 return nil
141}
142
143type standaloneServiceRecorder struct {
144 mu sync.Mutex
145 calls int
146 services []domain.StandaloneService
147 err error
148 onReconcile func()
149}
150
151func (r *standaloneServiceRecorder) Reconcile(_ context.Context, services []domain.StandaloneService) error {
152 r.mu.Lock()
153 r.calls++
154 r.services = append([]domain.StandaloneService(nil), services...)
155 err := r.err
156 onReconcile := r.onReconcile
157 r.mu.Unlock()
158 if onReconcile != nil {
159 onReconcile()
160 }
161 return err
162}
163
164func (r *standaloneServiceRecorder) Status(context.Context) ([]domain.StandaloneServiceStatus, error) {
165 return nil, nil
166}
167
168func (r *standaloneServiceRecorder) Calls() int {
169 r.mu.Lock()
170 defer r.mu.Unlock()
171 return r.calls
172}
173
174type publicTLSReconcileRecorder struct {
175 calls int
176}
177
178func (r *publicTLSReconcileRecorder) Reconcile(context.Context) error {
179 r.calls++
180 return nil
181}
182
183type eventBusRecorder struct {
184 mu sync.Mutex
185 calls int
186 eventType domain.EventType
187 payload any
188 err error
189}
190
191func (e *eventBusRecorder) Publish(eventType domain.EventType, payload any) error {
192 e.mu.Lock()
193 e.calls++
194 e.eventType = eventType
195 e.payload = payload
196 err := e.err
197 e.mu.Unlock()
198 return err
199}
200
201func (e *eventBusRecorder) Calls() int {
202 e.mu.Lock()
203 defer e.mu.Unlock()
204 return e.calls
205}
206
207func TestSetupConfigHotReload_WatchCallbackInvokesCoordinator(t *testing.T) {
208 ctx := context.Background()
209 configSvc := &watchRecorder{}
210 coordinator := &coordinatorRecorder{}
211
212 err := setupConfigHotReload(ctx, configSvc, coordinator)
213 require.NoError(t, err)
214 require.True(t, configSvc.called)
215 require.NotNil(t, configSvc.onChange)
216
217 configSvc.onChange()
218 require.Equal(t, 1, coordinator.ApplyCalls())
219 require.Equal(t, 0, coordinator.TriggerCalls())
220}
221
222func TestBuildProxyConfig_ParsesMaxBlobSizeWithoutChangingChunkDefault(t *testing.T) {
223 cfg := Config{}
224 cfg.Server.MaxBlobSize = "2GB"
225
226 result, err := buildProxyConfig(cfg, zerowrap.Default())
227
228 require.NoError(t, err)
229 assert.Equal(t, int64(2<<30), result.maxBlobSize)
230 assert.Equal(t, int64(registry.DefaultMaxBlobChunkSize), result.maxBlobChunkSize)
231}
232
233func TestBuildProxyConfig_DefaultMaxBlobSize(t *testing.T) {
234 result, err := buildProxyConfig(Config{}, zerowrap.Default())
235
236 require.NoError(t, err)
237 assert.Equal(t, int64(registry.DefaultMaxBlobSize), result.maxBlobSize)
238}
239
240func TestReloadCoordinator_ApplyLoadedConfig_RebuildsProxyConfigAndPublishesEvent(t *testing.T) {
241 ctx := context.Background()
242 v := viper.New()
243 v.Set("server.gordon_domain", "reload.example.com")
244 v.Set("server.registry_port", 5000)
245 v.Set("server.max_proxy_body_size", "5MB")
246 v.Set("server.max_blob_chunk_size", "6MB")
247 v.Set("server.max_blob_size", "8MB")
248 v.Set("server.max_proxy_response_size", "7MB")
249 v.Set("server.max_concurrent_connections", 99)
250 v.Set("server.legacy_registry_domains", []string{"old.example.com"})
251 v.Set("images.allowed_registries", []string{"docker.io"})
252
253 reloadSvc := &reloadRecorder{}
254 proxySvc := &proxyRecorder{}
255 events := &eventBusRecorder{}
256 containerCfg := &containerConfigApplyRecorder{}
257
258 registryLimits := ®istryLimitsRecorder{}
259 coord := newReloadCoordinator(v, reloadSvc, proxySvc, registryLimits, events, nil, zerowrap.Default())
260 coord.SetContainerConfigApplier(containerCfg.Apply)
261 require.NoError(t, coord.ApplyLoadedConfig(ctx))
262
263 require.Equal(t, 0, reloadSvc.Calls())
264 require.Equal(t, 1, proxySvc.calls)
265 require.Equal(t, 1, events.Calls())
266 require.Equal(t, 1, registryLimits.calls)
267 require.Equal(t, 1, containerCfg.calls)
268 require.Equal(t, int64(6<<20), registryLimits.maxBlobChunkSize)
269 require.Equal(t, int64(8<<20), registryLimits.maxBlobSize)
270 require.Equal(t, domain.EventConfigReload, events.eventType)
271 require.Equal(t, "reload.example.com", containerCfg.cfg.Server.GordonDomain)
272 require.Equal(t, []string{"old.example.com"}, containerCfg.cfg.Server.LegacyRegistryDomains)
273 require.Equal(t, []string{"docker.io"}, containerCfg.cfg.Images.AllowedRegistries)
274 require.Equal(t, proxy.Config{
275 RegistryDomain: "reload.example.com",
276 RegistryPort: 5000,
277 MaxBodySize: 5 << 20,
278 MaxResponseSize: 7 << 20,
279 MaxConcurrentConns: 99,
280 }, proxySvc.config)
281}
282
283func TestServiceInit_ReloadRegistersCustomTLSMuxHTTPSFallback(t *testing.T) {
284 ctx := context.Background()
285 v := viper.New()
286 v.Set("server.gordon_domain", "reload.example.com")
287 v.Set("server.registry_port", 5000)
288
289 configSvc := cfgusecase.NewService(v, nil)
290 require.NoError(t, configSvc.Load(ctx))
291 manager := trafficadapter.NewManager()
292 defer func() { require.NoError(t, manager.Shutdown(ctx)) }()
293 httpsHandler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
294 _, _ = w.Write([]byte("reload secure"))
295 })
296 pkiSvc := newTestPKIService(t)
297
298 customAddress := freeTCPAddress(t)
299 si := &serviceInit{
300 ctx: ctx,
301 v: v,
302 cfg: Config{},
303 log: zerowrap.Default(),
304 svc: &services{
305 configSvc: configSvc,
306 containerSvc: container.NewService(nil, nil, nil, nil, container.Config{}, configSvc),
307 internalRegUser: "gordon",
308 internalRegPass: "secret",
309 reloadCoordinator: newReloadCoordinator(v, &reloadRecorder{}, &proxyRecorder{}, nil, nil, nil, zerowrap.Default()),
310 trafficManager: manager,
311 httpsProxyHandler: httpsHandler,
312 pkiSvc: pkiSvc,
313 },
314 }
315 si.registerReloadCoordinatorHooks()
316
317 reloadCfg := Config{}
318 reloadCfg.Server.GordonDomain = "reload.example.com"
319 reloadCfg.Server.RegistryPort = 5000
320 reloadCfg.Server.TLSPort = freeTCPPort(t)
321 reloadCfg.EntryPoints = map[string]traffic.EntryPointConfig{
322 "custom-secure": {Address: customAddress, Protocol: domain.EntryPointProtocolTLSMux},
323 }
324
325 require.NoError(t, si.svc.reloadCoordinator.applyContainerConfig(ctx, reloadCfg))
326 managementCert, err := pkiSvc.GetCertificate(&tls.ClientHelloInfo{ServerName: "reload.example.com"})
327 require.NoError(t, err)
328 require.NotNil(t, managementCert)
329
330 _, portValue, err := net.SplitHostPort(customAddress)
331 require.NoError(t, err)
332 port, err := strconv.Atoi(portValue)
333 require.NoError(t, err)
334 body := httpsGetForTest(t, port, "app.example.com")
335 require.Contains(t, body, "reload secure")
336}
337
338func TestServiceInit_ReloadUpdatesManagementHostsBeforeLaterFailure(t *testing.T) {
339 ctx := t.Context()
340 v := viper.New()
341 v.Set("server.gordon_domain", "old.example.com")
342 v.Set("server.registry_port", 5000)
343
344 configSvc := cfgusecase.NewService(v, nil)
345 require.NoError(t, configSvc.Load(ctx))
346 publicTLS := inmocks.NewMockPublicTLSService(t)
347 publicTLS.EXPECT().SetAdditionalHosts(mock.Anything, []string{"new.example.com"}).Once()
348 reconcileErr := errors.New("standalone reconcile failed")
349
350 si := &serviceInit{
351 ctx: ctx,
352 v: v,
353 cfg: Config{},
354 log: zerowrap.Default(),
355 svc: &services{
356 configSvc: configSvc,
357 containerSvc: container.NewService(nil, nil, nil, nil, container.Config{}, configSvc),
358 internalRegUser: "gordon",
359 internalRegPass: "secret",
360 reloadCoordinator: newReloadCoordinator(v, &reloadRecorder{}, &proxyRecorder{}, nil, nil, publicTLS, zerowrap.Default()),
361 publicTLSSvc: publicTLS,
362 standaloneServiceSvc: &standaloneServiceRecorder{err: reconcileErr},
363 },
364 }
365 si.registerReloadCoordinatorHooks()
366
367 reloadCfg := Config{}
368 reloadCfg.Server.GordonDomain = "new.example.com"
369 reloadCfg.Server.RegistryPort = 5000
370 reloadCfg.Services = []servicecfg.Config{{Name: "game", Image: "game:latest", Enabled: true}}
371
372 err := si.svc.reloadCoordinator.applyContainerConfig(ctx, reloadCfg)
373 require.ErrorIs(t, err, reconcileErr)
374}
375
376func TestServiceInit_RegisterReloadCoordinatorHooks_WiresContainerConfigApplier(t *testing.T) {
377 ctx := context.Background()
378 v := viper.New()
379 v.Set("server.gordon_domain", "reload.example.com")
380 v.Set("server.registry_port", 5000)
381
382 configSvc := cfgusecase.NewService(v, nil)
383 require.NoError(t, configSvc.Load(ctx))
384
385 si := &serviceInit{
386 ctx: ctx,
387 v: v,
388 cfg: Config{},
389 log: zerowrap.Default(),
390 svc: &services{
391 configSvc: configSvc,
392 containerSvc: container.NewService(nil, nil, nil, nil, container.Config{}, configSvc),
393 internalRegUser: "gordon",
394 internalRegPass: "secret",
395 reloadCoordinator: newReloadCoordinator(v, &reloadRecorder{}, &proxyRecorder{}, nil, nil, nil, zerowrap.Default()),
396 },
397 }
398
399 si.registerReloadCoordinatorHooks()
400 require.NotNil(t, si.svc.reloadCoordinator.applyContainerConfig)
401
402 reloadCfg := Config{}
403 reloadCfg.Server.GordonDomain = "reload.example.com"
404 reloadCfg.Server.RegistryPort = 5000
405 reloadCfg.Images.AllowedRegistries = []string{"docker.io"}
406
407 require.NoError(t, si.svc.reloadCoordinator.applyContainerConfig(ctx, reloadCfg))
408}
409func TestStandaloneServiceSecretProviderUnsafeBackendReconcilesServiceSecrets(t *testing.T) {
410 ctx := context.Background()
411 dataDir := t.TempDir()
412 secretPath := "service/game/rcon"
413 require.NoError(t, os.MkdirAll(filepath.Join(dataDir, "secrets", "service", "game"), 0700))
414 require.NoError(t, os.WriteFile(filepath.Join(dataDir, "secrets", secretPath), []byte("test-secret-value"), 0600))
415
416 rt := outmocks.NewMockContainerRuntime(t)
417 var sawSecretEnv bool
418 rt.On("ListContainers", mock.Anything, true).Return([]*domain.Container{}, nil).Once()
419 rt.On("InspectImageVolumes", mock.Anything, "game:latest").Return([]string(nil), nil).Once()
420 rt.On("CreateContainer", mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Run(func(args mock.Arguments) {
421 config := args.Get(1).(*domain.ContainerConfig)
422 for _, entry := range config.Env {
423 if strings.HasPrefix(entry, "RCON_PASSWORD=") {
424 sawSecretEnv = true
425 }
426 }
427 }).Return(&domain.Container{ID: "created-1"}, nil).Once()
428 rt.On("StartContainer", mock.Anything, "created-1").Return(nil).Once()
429
430 provider := createStandaloneServiceSecretProvider(domain.SecretsBackendUnsafe, dataDir, zerowrap.Default())
431 reconciler := servicecfg.NewServiceWithSecretProvider(rt, provider)
432 err := reconciler.Reconcile(ctx, []domain.StandaloneService{{Name: "game", Image: "game:latest", Enabled: true, Secrets: []domain.StandaloneServiceSecretRef{{Name: "rcon", Key: "RCON_PASSWORD"}}}})
433
434 require.NoError(t, err)
435 assert.True(t, sawSecretEnv)
436}
437
438func TestServiceInit_RegisterReloadCoordinatorHooks_AppliesTrafficBeforeStandaloneServices(t *testing.T) {
439 ctx := context.Background()
440 v := viper.New()
441 v.Set("server.gordon_domain", "reload.example.com")
442 v.Set("server.registry_port", 5000)
443
444 configSvc := cfgusecase.NewService(v, nil)
445 require.NoError(t, configSvc.Load(ctx))
446 manager := trafficadapter.NewManager()
447 defer func() { require.NoError(t, manager.Shutdown(ctx)) }()
448
449 reconciler := &standaloneServiceRecorder{onReconcile: func() {
450 assert.NotEmpty(t, manager.Status().EntryPoints, "traffic graph should apply before standalone services reconcile")
451 }}
452 si := &serviceInit{
453 ctx: ctx,
454 v: v,
455 cfg: Config{},
456 log: zerowrap.Default(),
457 svc: &services{
458 configSvc: configSvc,
459 containerSvc: container.NewService(nil, nil, nil, nil, container.Config{}, configSvc),
460 internalRegUser: "gordon",
461 internalRegPass: "secret",
462 reloadCoordinator: newReloadCoordinator(v, &reloadRecorder{}, &proxyRecorder{}, nil, nil, nil, zerowrap.Default()),
463 trafficManager: manager,
464 standaloneServiceSvc: reconciler,
465 },
466 }
467 si.registerReloadCoordinatorHooks()
468
469 reloadCfg := Config{}
470 reloadCfg.Server.GordonDomain = "reload.example.com"
471 reloadCfg.Server.RegistryPort = 5000
472 reloadCfg.Services = []servicecfg.Config{{Name: "game", Image: "game:latest", Enabled: true}}
473 reloadCfg.EntryPoints = map[string]traffic.EntryPointConfig{"game": {Address: freeTCPAddress(t), Protocol: domain.EntryPointProtocolTCP}}
474 reloadCfg.NetworkServices = []traffic.NetworkServiceConfig{{Name: "game", Ports: []traffic.PortConfig{{Name: "game", Container: 28015, Protocol: domain.NetworkProtocolTCP}}}}
475 reloadCfg.Traffic.TCP.Routers = []traffic.RouterConfig{{Name: "game", EntryPoint: "game", Service: "network_service:game:game"}}
476
477 require.NoError(t, si.svc.reloadCoordinator.applyContainerConfig(ctx, reloadCfg))
478 require.Equal(t, 1, reconciler.Calls())
479 require.Len(t, reconciler.services, 1)
480 assert.Equal(t, "game", reconciler.services[0].Name)
481 assert.NotEmpty(t, manager.Status().EntryPoints)
482}
483
484func TestWaitForCoreProxyReadyAndApplyTraffic_AppliesTrafficBeforeStandaloneServices(t *testing.T) {
485 ctx := context.Background()
486 v := viper.New()
487 configSvc := cfgusecase.NewService(v, nil)
488 require.NoError(t, configSvc.Load(ctx))
489 manager := trafficadapter.NewManager()
490 defer func() { require.NoError(t, manager.Shutdown(ctx)) }()
491
492 reconciler := &standaloneServiceRecorder{onReconcile: func() {
493 assert.NotEmpty(t, manager.Status().EntryPoints, "traffic graph should apply before standalone services reconcile")
494 }}
495 cfg := Config{}
496 cfg.Services = []servicecfg.Config{{Name: "game", Image: "game:latest", Enabled: true}}
497 cfg.EntryPoints = map[string]traffic.EntryPointConfig{"game": {Address: freeTCPAddress(t), Protocol: domain.EntryPointProtocolTCP}}
498 cfg.NetworkServices = []traffic.NetworkServiceConfig{{Name: "game", Ports: []traffic.PortConfig{{Name: "game", Container: 28015, Protocol: domain.NetworkProtocolTCP}}}}
499 cfg.Traffic.TCP.Routers = []traffic.RouterConfig{{Name: "game", EntryPoint: "game", Service: "network_service:game:game"}}
500
501 ready := make(chan struct{})
502 close(ready)
503 svc := &services{configSvc: configSvc, trafficManager: manager, standaloneServiceSvc: reconciler}
504 require.NoError(t, waitForCoreProxyReadyAndApplyTraffic(ctx, cfg, svc, ready, ready, nil))
505 require.Equal(t, 1, reconciler.Calls())
506 assert.NotEmpty(t, manager.Status().EntryPoints)
507}
508
509func TestReconcileStandaloneServices_ConfigConversionErrorIsClear(t *testing.T) {
510 reconciler := &standaloneServiceRecorder{}
511 err := reconcileStandaloneServices(context.Background(), reconciler, Config{Services: []servicecfg.Config{{Name: "broken", Image: "", Enabled: true}}})
512 require.Error(t, err)
513 assert.ErrorContains(t, err, "convert standalone service config")
514 assert.Equal(t, 0, reconciler.Calls())
515}
516
517func TestReloadCoordinator_DebouncesRepeatedWatchCallbacks(t *testing.T) {
518 ctx := context.Background()
519 v := viper.New()
520 v.Set("server.gordon_domain", "new.example.com")
521 v.Set("server.registry_port", 5000)
522 v.Set("server.max_proxy_body_size", "5MB")
523 v.Set("server.max_blob_chunk_size", "6MB")
524 v.Set("server.max_blob_size", "8MB")
525 v.Set("server.max_proxy_response_size", "7MB")
526 v.Set("server.max_concurrent_connections", 99)
527
528 reloadSvc := &reloadRecorder{}
529 proxySvc := &proxyRecorder{}
530 events := &eventBusRecorder{}
531
532 registryLimits := ®istryLimitsRecorder{}
533 coord := newReloadCoordinator(v, reloadSvc, proxySvc, registryLimits, events, nil, zerowrap.Default())
534 require.NoError(t, coord.Trigger(ctx))
535 require.NoError(t, coord.Trigger(ctx))
536
537 require.Equal(t, 1, reloadSvc.Calls())
538 require.Equal(t, 1, proxySvc.calls)
539 require.Equal(t, 1, events.Calls())
540 require.Equal(t, 1, registryLimits.calls)
541 require.Equal(t, int64(6<<20), registryLimits.maxBlobChunkSize)
542 require.Equal(t, int64(8<<20), registryLimits.maxBlobSize)
543 require.Equal(t, domain.EventConfigReload, events.eventType)
544 require.Equal(t, proxy.Config{
545 RegistryDomain: "new.example.com",
546 RegistryPort: 5000,
547 MaxBodySize: 5 << 20,
548 MaxResponseSize: 7 << 20,
549 MaxConcurrentConns: 99,
550 }, proxySvc.config)
551}
552
553func TestReloadCoordinator_RetriesImmediatelyAfterFailedReload(t *testing.T) {
554 ctx := context.Background()
555 v := viper.New()
556 v.Set("server.gordon_domain", "retry.example.com")
557 v.Set("server.registry_port", 5000)
558 v.Set("server.max_proxy_body_size", "2MB")
559 v.Set("server.max_blob_chunk_size", "3MB")
560 v.Set("server.max_proxy_response_size", "4MB")
561 v.Set("server.max_concurrent_connections", 11)
562
563 reloadErr := errors.New("reload failed")
564 reloadSvc := &reloadRecorder{err: reloadErr}
565 proxySvc := &proxyRecorder{}
566 coord := newReloadCoordinator(v, reloadSvc, proxySvc, nil, nil, nil, zerowrap.Default())
567
568 err := coord.Trigger(ctx)
569 require.ErrorIs(t, err, reloadErr)
570
571 reloadSvc.mu.Lock()
572 reloadSvc.err = nil
573 reloadSvc.mu.Unlock()
574
575 require.NoError(t, coord.Trigger(ctx))
576 require.Equal(t, 2, reloadSvc.Calls())
577 assert.Equal(t, 1, proxySvc.calls)
578}
579
580func TestReloadCoordinator_PublishErrorDoesNotAdvanceDebounceState(t *testing.T) {
581 ctx := context.Background()
582 v := viper.New()
583 v.Set("server.gordon_domain", "publish.example.com")
584 v.Set("server.registry_port", 5000)
585 v.Set("server.max_proxy_body_size", "5MB")
586 v.Set("server.max_blob_chunk_size", "6MB")
587 v.Set("server.max_proxy_response_size", "7MB")
588 v.Set("server.max_concurrent_connections", 99)
589
590 publishErr := errors.New("publish failed")
591 reloadSvc := &reloadRecorder{}
592 proxySvc := &proxyRecorder{}
593 events := &eventBusRecorder{err: publishErr}
594 publicTLS := &publicTLSReconcileRecorder{}
595
596 coord := newReloadCoordinator(v, reloadSvc, proxySvc, nil, events, publicTLS, zerowrap.Default())
597
598 err := coord.Trigger(ctx)
599 require.ErrorIs(t, err, publishErr)
600
601 err = coord.Trigger(ctx)
602 require.ErrorIs(t, err, publishErr)
603
604 require.Equal(t, 2, reloadSvc.Calls())
605 require.Equal(t, 2, proxySvc.calls)
606 require.Equal(t, 2, events.Calls())
607 require.Equal(t, 2, publicTLS.calls)
608 require.Equal(t, domain.EventConfigReload, events.eventType)
609}
610
611func TestReloadCoordinator_SerializesOverlappingReloadRequests(t *testing.T) {
612 ctx := context.Background()
613 v := viper.New()
614 v.Set("server.gordon_domain", "app.example.com")
615 v.Set("server.registry_port", 5000)
616 v.Set("server.max_proxy_body_size", "2MB")
617 v.Set("server.max_blob_chunk_size", "3MB")
618 v.Set("server.max_proxy_response_size", "4MB")
619 v.Set("server.max_concurrent_connections", 11)
620
621 started := make(chan struct{})
622 release := make(chan struct{})
623 var once sync.Once
624 reloadSvc := &reloadRecorder{onReload: func() {
625 once.Do(func() { close(started) })
626 <-release
627 }}
628 proxySvc := &proxyRecorder{}
629 coord := newReloadCoordinator(v, reloadSvc, proxySvc, nil, nil, nil, zerowrap.Default())
630
631 firstDone := make(chan struct{})
632 go func() {
633 _ = coord.Trigger(ctx)
634 close(firstDone)
635 }()
636
637 select {
638 case <-started:
639 case <-time.After(2 * time.Second):
640 t.Fatal("first reload did not start")
641 }
642
643 secondDone := make(chan struct{})
644 go func() {
645 _ = coord.Trigger(ctx)
646 close(secondDone)
647 }()
648
649 select {
650 case <-secondDone:
651 t.Fatal("second reload finished before the first was released")
652 case <-time.After(75 * time.Millisecond):
653 }
654
655 require.Equal(t, 1, reloadSvc.Calls())
656 close(release)
657
658 select {
659 case <-firstDone:
660 case <-time.After(2 * time.Second):
661 t.Fatal("first reload did not finish")
662 }
663
664 select {
665 case <-secondDone:
666 case <-time.After(2 * time.Second):
667 t.Fatal("second reload did not finish")
668 }
669
670 require.Equal(t, 1, reloadSvc.Calls())
671 assert.Equal(t, 1, proxySvc.calls)
672}