package app import ( "context" "crypto/tls" "errors" "net" "net/http" "path/filepath" "strconv" "sync" "testing" "time" "github.com/bnema/zerowrap" "github.com/spf13/viper" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" "github.com/bnema/gordon/internal/adapters/in/http/registry" trafficadapter "github.com/bnema/gordon/internal/adapters/in/traffic" inmocks "github.com/bnema/gordon/internal/boundaries/in/mocks" outmocks "github.com/bnema/gordon/internal/boundaries/out/mocks" "github.com/bnema/gordon/internal/domain" "github.com/bnema/gordon/internal/usecase/apps" cfgusecase "github.com/bnema/gordon/internal/usecase/config" "github.com/bnema/gordon/internal/usecase/container" "github.com/bnema/gordon/internal/usecase/proxy" traffic "github.com/bnema/gordon/internal/usecase/traffic" ) type watchRecorder struct { called bool onChange func() } func (w *watchRecorder) Watch(ctx context.Context, onChange func()) error { w.called = true w.onChange = onChange return nil } type coordinatorRecorder struct { mu sync.Mutex triggerCalls int applyCalls int err error } func (c *coordinatorRecorder) Trigger(context.Context) error { c.mu.Lock() c.triggerCalls++ err := c.err c.mu.Unlock() return err } func (c *coordinatorRecorder) ApplyLoadedConfig(context.Context) error { c.mu.Lock() c.applyCalls++ err := c.err c.mu.Unlock() return err } func (c *coordinatorRecorder) TriggerCalls() int { c.mu.Lock() defer c.mu.Unlock() return c.triggerCalls } func (c *coordinatorRecorder) ApplyCalls() int { c.mu.Lock() defer c.mu.Unlock() return c.applyCalls } type reloadRecorder struct { mu sync.Mutex calls int err error onReload func() } func (r *reloadRecorder) Reload(context.Context) error { r.mu.Lock() r.calls++ err := r.err onReload := r.onReload r.mu.Unlock() if onReload != nil { onReload() } return err } func (r *reloadRecorder) Calls() int { r.mu.Lock() defer r.mu.Unlock() return r.calls } type proxyRecorder struct { mu sync.Mutex calls int config proxy.Config } func (p *proxyRecorder) UpdateConfig(config proxy.Config) { p.mu.Lock() defer p.mu.Unlock() p.calls++ p.config = config } func (p *proxyRecorder) Calls() int { p.mu.Lock() defer p.mu.Unlock() return p.calls } func (p *proxyRecorder) Config() proxy.Config { p.mu.Lock() defer p.mu.Unlock() return p.config } type registryLimitsRecorder struct { mu sync.Mutex calls int maxBlobChunkSize int64 maxBlobSize int64 } func (r *registryLimitsRecorder) UpdateBlobLimits(maxBlobChunkSize, maxBlobSize int64) { r.mu.Lock() defer r.mu.Unlock() r.calls++ r.maxBlobChunkSize = maxBlobChunkSize r.maxBlobSize = maxBlobSize } func (r *registryLimitsRecorder) Calls() int { r.mu.Lock() defer r.mu.Unlock() return r.calls } func (r *registryLimitsRecorder) Limits() (chunk, size int64) { r.mu.Lock() defer r.mu.Unlock() return r.maxBlobChunkSize, r.maxBlobSize } type containerConfigApplyRecorder struct { calls int ctx context.Context cfg Config } func (r *containerConfigApplyRecorder) Apply(ctx context.Context, cfg Config) error { r.calls++ r.ctx = ctx r.cfg = cfg return nil } type publicTLSReconcileRecorder struct { calls int } func (r *publicTLSReconcileRecorder) Reconcile(context.Context) error { r.calls++ return nil } type eventBusRecorder struct { mu sync.Mutex calls int eventType domain.EventType payload any err error } func (e *eventBusRecorder) Publish(eventType domain.EventType, payload any) error { e.mu.Lock() e.calls++ e.eventType = eventType e.payload = payload err := e.err e.mu.Unlock() return err } func (e *eventBusRecorder) Calls() int { e.mu.Lock() defer e.mu.Unlock() return e.calls } func (e *eventBusRecorder) Type() domain.EventType { e.mu.Lock() defer e.mu.Unlock() return e.eventType } func TestSetupConfigHotReload_WatchCallbackInvokesCoordinator(t *testing.T) { ctx := context.Background() configSvc := &watchRecorder{} coordinator := &coordinatorRecorder{} err := setupConfigHotReload(ctx, configSvc, coordinator) require.NoError(t, err) require.True(t, configSvc.called) require.NotNil(t, configSvc.onChange) configSvc.onChange() require.Equal(t, 1, coordinator.ApplyCalls()) require.Equal(t, 0, coordinator.TriggerCalls()) } func TestBuildProxyConfig_ParsesMaxBlobSizeWithoutChangingChunkDefault(t *testing.T) { cfg := Config{} cfg.Server.MaxBlobSize = "2GB" result, err := buildProxyConfig(cfg, zerowrap.Default()) require.NoError(t, err) assert.Equal(t, int64(2<<30), result.maxBlobSize) assert.Equal(t, int64(registry.DefaultMaxBlobChunkSize), result.maxBlobChunkSize) } func TestBuildProxyConfig_DefaultMaxBlobSize(t *testing.T) { result, err := buildProxyConfig(Config{}, zerowrap.Default()) require.NoError(t, err) assert.Equal(t, int64(registry.DefaultMaxBlobSize), result.maxBlobSize) } func TestReloadCoordinator_ApplyLoadedConfig_RebuildsProxyConfigAndPublishesEvent(t *testing.T) { ctx := context.Background() v := viper.New() v.Set("server.gordon_domain", "reload.example.com") v.Set("server.registry_port", 5000) v.Set("server.max_proxy_body_size", "5MB") v.Set("server.max_blob_chunk_size", "6MB") v.Set("server.max_blob_size", "8MB") v.Set("server.max_proxy_response_size", "7MB") v.Set("server.max_concurrent_connections", 99) v.Set("server.legacy_registry_domains", []string{"old.example.com"}) v.Set("images.allowed_registries", []string{"docker.io"}) reloadSvc := &reloadRecorder{} proxySvc := &proxyRecorder{} events := &eventBusRecorder{} containerCfg := &containerConfigApplyRecorder{} registryLimits := ®istryLimitsRecorder{} coord := newReloadCoordinator(v, reloadSvc, proxySvc, registryLimits, events, nil, containerCfg.Apply, zerowrap.Default()) require.NoError(t, coord.ApplyLoadedConfig(ctx)) require.Equal(t, 0, reloadSvc.Calls()) require.Equal(t, 1, proxySvc.Calls()) require.Equal(t, 1, events.Calls()) require.Equal(t, 1, registryLimits.Calls()) require.Equal(t, 1, containerCfg.calls) rlChunk, rlSize := registryLimits.Limits() require.Equal(t, int64(6<<20), rlChunk) require.Equal(t, int64(8<<20), rlSize) require.Equal(t, domain.EventConfigReload, events.Type()) require.Equal(t, "reload.example.com", containerCfg.cfg.Server.GordonDomain) require.Equal(t, []string{"old.example.com"}, containerCfg.cfg.Server.LegacyRegistryDomains) require.Equal(t, []string{"docker.io"}, containerCfg.cfg.Images.AllowedRegistries) require.Equal(t, proxy.Config{ RegistryDomain: "reload.example.com", RegistryPort: 5000, MaxBodySize: 5 << 20, MaxResponseSize: 7 << 20, MaxConcurrentConns: 99, }, proxySvc.Config()) } // newTestServices wires the serialized traffic publisher the reload hook // requires. Tests that omit app state still exercise the graph apply. func newTestServices(svc *services) *services { svc.appTrafficPublisher = newAppTrafficPublisher(svc, Config{}) return svc } func TestReloadRuntime_RegistersCustomTLSMuxHTTPSFallback(t *testing.T) { ctx := context.Background() v := viper.New() v.Set("server.gordon_domain", "reload.example.com") v.Set("server.registry_port", 5000) configSvc := cfgusecase.NewService(v, nil) require.NoError(t, configSvc.Load(ctx)) manager := trafficadapter.NewManager() defer func() { require.NoError(t, manager.Shutdown(ctx)) }() httpsHandler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte("reload secure")) }) pkiSvc := newTestPKIService(t) customAddress := freeTCPAddress(t) si := &serviceInit{ ctx: ctx, v: v, cfg: Config{}, log: zerowrap.Default(), svc: newTestServices(&services{ configSvc: configSvc, containerSvc: container.NewService(nil, nil, nil, container.Config{}), internalRegUser: "gordon", internalRegPass: "secret", reloadCoordinator: newReloadCoordinator(v, &reloadRecorder{}, &proxyRecorder{}, nil, nil, nil, nil, zerowrap.Default()), trafficManager: manager, httpsProxyHandler: httpsHandler, pkiSvc: pkiSvc, }), } reloadCfg := Config{} reloadCfg.Server.GordonDomain = "reload.example.com" reloadCfg.Server.RegistryPort = 5000 reloadCfg.Server.TLSPort = freeTCPPort(t) reloadCfg.EntryPoints = map[string]traffic.EntryPointConfig{ "custom-secure": {Address: customAddress, Protocol: domain.EntryPointProtocolTLSMux}, } require.NoError(t, (&reloadRuntime{v: si.v, svc: si.svc, log: si.log}).Apply(ctx, reloadCfg)) managementCert, err := pkiSvc.GetCertificate(&tls.ClientHelloInfo{ServerName: "reload.example.com"}) require.NoError(t, err) require.NotNil(t, managementCert) _, portValue, err := net.SplitHostPort(customAddress) require.NoError(t, err) port, err := strconv.Atoi(portValue) require.NoError(t, err) body := httpsGetForTest(t, port, "app.example.com") require.Contains(t, body, "reload secure") } func TestReloadRuntime_UpdatesManagementHosts(t *testing.T) { ctx := t.Context() v := viper.New() v.Set("server.gordon_domain", "old.example.com") v.Set("server.registry_port", 5000) configSvc := cfgusecase.NewService(v, nil) require.NoError(t, configSvc.Load(ctx)) publicTLS := inmocks.NewMockPublicTLSService(t) publicTLS.EXPECT().SetAdditionalHosts(mock.Anything, []string{"new.example.com"}).Once() si := &serviceInit{ ctx: ctx, v: v, cfg: Config{}, log: zerowrap.Default(), svc: newTestServices(&services{ configSvc: configSvc, containerSvc: container.NewService(nil, nil, nil, container.Config{}), internalRegUser: "gordon", internalRegPass: "secret", reloadCoordinator: newReloadCoordinator(v, &reloadRecorder{}, &proxyRecorder{}, nil, nil, publicTLS, nil, zerowrap.Default()), publicTLSSvc: publicTLS, }), } reloadCfg := Config{} reloadCfg.Server.GordonDomain = "new.example.com" reloadCfg.Server.RegistryPort = 5000 require.NoError(t, (&reloadRuntime{v: si.v, svc: si.svc, log: si.log}).Apply(ctx, reloadCfg)) } func TestReloadRuntime_AppliesContainerConfig(t *testing.T) { ctx := context.Background() v := viper.New() v.Set("server.gordon_domain", "reload.example.com") v.Set("server.registry_port", 5000) configSvc := cfgusecase.NewService(v, nil) require.NoError(t, configSvc.Load(ctx)) si := &serviceInit{ ctx: ctx, v: v, cfg: Config{}, log: zerowrap.Default(), svc: newTestServices(&services{ configSvc: configSvc, containerSvc: container.NewService(nil, nil, nil, container.Config{}), internalRegUser: "gordon", internalRegPass: "secret", reloadCoordinator: newReloadCoordinator(v, &reloadRecorder{}, &proxyRecorder{}, nil, nil, nil, nil, zerowrap.Default()), }), } reloadCfg := Config{} reloadCfg.Server.GordonDomain = "reload.example.com" reloadCfg.Server.RegistryPort = 5000 reloadCfg.Images.AllowedRegistries = []string{"docker.io"} require.NoError(t, (&reloadRuntime{v: si.v, svc: si.svc, log: si.log}).Apply(ctx, reloadCfg)) } func TestReloadRuntime_AppliesTraffic(t *testing.T) { ctx := context.Background() v := viper.New() v.Set("server.gordon_domain", "reload.example.com") v.Set("server.registry_port", 5000) configSvc := cfgusecase.NewService(v, nil) require.NoError(t, configSvc.Load(ctx)) manager := trafficadapter.NewManager() defer func() { require.NoError(t, manager.Shutdown(ctx)) }() si := &serviceInit{ ctx: ctx, v: v, cfg: Config{}, log: zerowrap.Default(), svc: newTestServices(&services{ configSvc: configSvc, containerSvc: container.NewService(nil, nil, nil, container.Config{}), internalRegUser: "gordon", internalRegPass: "secret", reloadCoordinator: newReloadCoordinator(v, &reloadRecorder{}, &proxyRecorder{}, nil, nil, nil, nil, zerowrap.Default()), trafficManager: manager, }), } reloadCfg := Config{} reloadCfg.Server.GordonDomain = "reload.example.com" reloadCfg.Server.RegistryPort = 5000 reloadCfg.EntryPoints = map[string]traffic.EntryPointConfig{"game": {Address: freeTCPAddress(t), Protocol: domain.EntryPointProtocolTCP}} reloadCfg.NetworkServices = []traffic.NetworkServiceConfig{{Name: "game", Ports: []traffic.PortConfig{{Name: "game", Container: 28015, Protocol: domain.NetworkProtocolTCP}}}} reloadCfg.Traffic.TCP.Routers = []traffic.RouterConfig{{Name: "game", EntryPoint: "game", Service: "network_service:game:game"}} require.NoError(t, (&reloadRuntime{v: si.v, svc: si.svc, log: si.log}).Apply(ctx, reloadCfg)) assert.NotEmpty(t, manager.Status().EntryPoints) } // TestReloadRuntime_InvalidPolicyAppliesNothing proves the runtime step // prepares every derived value first: an invalid app mount fails the reload // before management hosts, traffic, or container config change. func TestReloadRuntime_InvalidPolicyAppliesNothing(t *testing.T) { ctx := t.Context() v := viper.New() publicTLS := inmocks.NewMockPublicTLSService(t) // no expectations: any call fails store := outmocks.NewMockAppState(t) svc := newTestServices(&services{ containerSvc: container.NewService(nil, nil, nil, container.Config{}), publicTLSSvc: publicTLS, appSvcImpl: apps.NewAppServiceImpl(store, nil, nil, zerowrap.Default()), }) reloadCfg := Config{} reloadCfg.Server.GordonDomain = "new.example.com" reloadCfg.AppMounts = map[string]AppMountPolicy{"config": {Source: "relative"}} err := (&reloadRuntime{v: v, svc: svc, log: zerowrap.Default()}).Apply(ctx, reloadCfg) require.ErrorIs(t, err, domain.ErrBindPolicy) } // TestReloadRuntime_TLSLoadFailureAppliesNothing proves an unreadable static // TLS keypair fails the reload before management hosts change. func TestReloadRuntime_TLSLoadFailureAppliesNothing(t *testing.T) { ctx := t.Context() publicTLS := inmocks.NewMockPublicTLSService(t) // no expectations: any call fails svc := newTestServices(&services{ containerSvc: container.NewService(nil, nil, nil, container.Config{}), publicTLSSvc: publicTLS, httpsProxyHandler: http.NotFoundHandler(), }) reloadCfg := Config{} reloadCfg.Server.GordonDomain = "new.example.com" reloadCfg.Server.TLSCertFile = filepath.Join(t.TempDir(), "missing.crt") reloadCfg.Server.TLSKeyFile = filepath.Join(t.TempDir(), "missing.key") reloadCfg.EntryPoints = map[string]traffic.EntryPointConfig{ "secure": {Address: freeTCPAddress(t), Protocol: domain.EntryPointProtocolTLSMux}, } err := (&reloadRuntime{v: viper.New(), svc: svc, log: zerowrap.Default()}).Apply(ctx, reloadCfg) require.ErrorContains(t, err, "load TLS keypair") } func TestWaitForCoreProxyReady_WaitsWithoutPublishingEmptyTraffic(t *testing.T) { ctx := context.Background() manager := trafficadapter.NewManager() defer func() { require.NoError(t, manager.Shutdown(ctx)) }() ready := make(chan struct{}) close(ready) require.NoError(t, waitForCoreProxyReady(ctx, ready, ready, nil)) assert.Empty(t, manager.Status().EntryPoints) } func TestWaitForCoreProxyReady_ObservesContextCancellation(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) cancel() // Neither readiness channel ever closes: the canceled startup context must // unblock the wait rather than hang on a listener that will never bind. err := waitForCoreProxyReady(ctx, make(chan struct{}), make(chan struct{}), make(chan error)) require.ErrorIs(t, err, context.Canceled) } func TestReloadCoordinator_StopCancelsOwnedTrailingReloadLifecycle(t *testing.T) { ctx := context.Background() v := viper.New() v.Set("server.gordon_domain", "lifecycle.example.com") v.Set("server.registry_port", 5000) reloadSvc := &reloadRecorder{} proxySvc := &proxyRecorder{} coord := newReloadCoordinator(v, reloadSvc, proxySvc, nil, nil, nil, nil, zerowrap.Default()) // Keep the trailing timer pending so Stop is the only thing that can tear // the coordinator-owned lifecycle down. coord.debounce = time.Hour require.NoError(t, coord.Trigger(ctx)) // immediate apply records lastRun require.NoError(t, coord.Trigger(ctx)) // coalesced into the trailing timer coord.mu.Lock() lifecycleCtx := coord.lifecycleCtx coord.mu.Unlock() require.NotNil(t, lifecycleCtx, "trailing reload must own a lifecycle context") require.NoError(t, lifecycleCtx.Err()) coord.Stop() require.ErrorIs(t, lifecycleCtx.Err(), context.Canceled) require.Equal(t, 1, proxySvc.Calls(), "stopped coordinator must not apply the trailing reload") } func TestReloadCoordinator_DebouncesRepeatedWatchCallbacks(t *testing.T) { ctx := context.Background() v := viper.New() v.Set("server.gordon_domain", "new.example.com") v.Set("server.registry_port", 5000) v.Set("server.max_proxy_body_size", "5MB") v.Set("server.max_blob_chunk_size", "6MB") v.Set("server.max_blob_size", "8MB") v.Set("server.max_proxy_response_size", "7MB") v.Set("server.max_concurrent_connections", 99) reloadSvc := &reloadRecorder{} proxySvc := &proxyRecorder{} events := &eventBusRecorder{} registryLimits := ®istryLimitsRecorder{} coord := newReloadCoordinator(v, reloadSvc, proxySvc, registryLimits, events, nil, nil, zerowrap.Default()) coord.debounce = 20 * time.Millisecond require.NoError(t, coord.Trigger(ctx)) require.NoError(t, coord.Trigger(ctx)) require.NoError(t, coord.Trigger(ctx)) // Observe the trailing reload through the recorders' own locks instead of // locking the coordinator: the reload event is published last, so waiting // for all recorders proves the trailing reload finished applying. require.Eventually(t, func() bool { return reloadSvc.Calls() == 2 && proxySvc.Calls() == 2 && events.Calls() == 2 && registryLimits.Calls() == 2 }, time.Second, 5*time.Millisecond) // No further reload may fire: the debounce window is over. require.Never(t, func() bool { return proxySvc.Calls() > 2 || events.Calls() > 2 }, 50*time.Millisecond, 5*time.Millisecond) require.Equal(t, 2, proxySvc.Calls()) require.Equal(t, 2, events.Calls()) require.Equal(t, 2, registryLimits.Calls()) rlChunk, rlSize := registryLimits.Limits() require.Equal(t, int64(6<<20), rlChunk) require.Equal(t, int64(8<<20), rlSize) require.Equal(t, domain.EventConfigReload, events.Type()) require.Equal(t, proxy.Config{ RegistryDomain: "new.example.com", RegistryPort: 5000, MaxBodySize: 5 << 20, MaxResponseSize: 7 << 20, MaxConcurrentConns: 99, }, proxySvc.Config()) } func TestSetupConfigHotReload_CoalescesRepeatedWatcherCallbacks(t *testing.T) { ctx := context.Background() v := viper.New() v.Set("server.gordon_domain", "watch.example.com") v.Set("server.registry_port", 5000) configSvc := &watchRecorder{} reloadSvc := &reloadRecorder{} proxySvc := &proxyRecorder{} coord := newReloadCoordinator(v, reloadSvc, proxySvc, nil, nil, nil, nil, zerowrap.Default()) coord.debounce = 20 * time.Millisecond defer coord.Stop() require.NoError(t, setupConfigHotReload(ctx, configSvc, coord)) require.NotNil(t, configSvc.onChange) // fsnotify delivers a burst of events for a single edit; each callback // must not apply the config on its own. configSvc.onChange() configSvc.onChange() configSvc.onChange() require.Eventually(t, func() bool { return proxySvc.Calls() == 2 }, time.Second, 5*time.Millisecond) require.Never(t, func() bool { return proxySvc.Calls() > 2 }, 50*time.Millisecond, 5*time.Millisecond) // ApplyLoadedConfig applies what the watcher already loaded: the trailing // apply must not read the config again. require.Zero(t, reloadSvc.Calls()) require.Equal(t, "watch.example.com", proxySvc.Config().RegistryDomain) } func TestReloadCoordinator_TrailingReloadAppliesFinalState(t *testing.T) { ctx := context.Background() v := viper.New() v.Set("server.gordon_domain", "initial.example.com") v.Set("server.registry_port", 5000) reloadSvc := &reloadRecorder{} proxySvc := &proxyRecorder{} coord := newReloadCoordinator(v, reloadSvc, proxySvc, nil, nil, nil, nil, zerowrap.Default()) coord.debounce = 20 * time.Millisecond require.NoError(t, coord.Trigger(ctx)) v.Set("server.gordon_domain", "intermediate.example.com") require.NoError(t, coord.Trigger(ctx)) v.Set("server.gordon_domain", "final.example.com") require.NoError(t, coord.Trigger(ctx)) // The trailing reload is the only writer of the final state: wait for // the observable outcome instead of locking the coordinator. require.Eventually(t, func() bool { return proxySvc.Config().RegistryDomain == "final.example.com" }, time.Second, 5*time.Millisecond) } func TestReloadCoordinator_InvalidReloadKeepsActiveConfigThenAcceptsValidChange(t *testing.T) { ctx := context.Background() v := viper.New() v.Set("server.gordon_domain", "active.example.com") v.Set("server.registry_port", 5000) reloadSvc := &reloadRecorder{} proxySvc := &proxyRecorder{} coord := newReloadCoordinator(v, reloadSvc, proxySvc, nil, nil, nil, nil, zerowrap.Default()) coord.debounce = 10 * time.Millisecond require.NoError(t, coord.Trigger(ctx)) require.Equal(t, "active.example.com", proxySvc.Config().RegistryDomain) time.Sleep(coord.debounce) v.Set("server.max_proxy_body_size", "invalid") require.Error(t, coord.Trigger(ctx)) require.Equal(t, "active.example.com", proxySvc.Config().RegistryDomain) v.Set("server.max_proxy_body_size", "5MB") v.Set("server.gordon_domain", "recovered.example.com") require.NoError(t, coord.Trigger(ctx)) require.Equal(t, "recovered.example.com", proxySvc.Config().RegistryDomain) } func TestReloadCoordinator_RetriesImmediatelyAfterFailedReload(t *testing.T) { ctx := context.Background() v := viper.New() v.Set("server.gordon_domain", "retry.example.com") v.Set("server.registry_port", 5000) v.Set("server.max_proxy_body_size", "2MB") v.Set("server.max_blob_chunk_size", "3MB") v.Set("server.max_proxy_response_size", "4MB") v.Set("server.max_concurrent_connections", 11) reloadErr := errors.New("reload failed") reloadSvc := &reloadRecorder{err: reloadErr} proxySvc := &proxyRecorder{} coord := newReloadCoordinator(v, reloadSvc, proxySvc, nil, nil, nil, nil, zerowrap.Default()) err := coord.Trigger(ctx) require.ErrorIs(t, err, reloadErr) reloadSvc.mu.Lock() reloadSvc.err = nil reloadSvc.mu.Unlock() require.NoError(t, coord.Trigger(ctx)) require.Equal(t, 2, reloadSvc.Calls()) assert.Equal(t, 1, proxySvc.Calls()) } func TestReloadCoordinator_PublishErrorDoesNotAdvanceDebounceState(t *testing.T) { ctx := context.Background() v := viper.New() v.Set("server.gordon_domain", "publish.example.com") v.Set("server.registry_port", 5000) v.Set("server.max_proxy_body_size", "5MB") v.Set("server.max_blob_chunk_size", "6MB") v.Set("server.max_proxy_response_size", "7MB") v.Set("server.max_concurrent_connections", 99) publishErr := errors.New("publish failed") reloadSvc := &reloadRecorder{} proxySvc := &proxyRecorder{} events := &eventBusRecorder{err: publishErr} publicTLS := &publicTLSReconcileRecorder{} coord := newReloadCoordinator(v, reloadSvc, proxySvc, nil, events, publicTLS, nil, zerowrap.Default()) err := coord.Trigger(ctx) require.ErrorIs(t, err, publishErr) err = coord.Trigger(ctx) require.ErrorIs(t, err, publishErr) require.Equal(t, 2, reloadSvc.Calls()) require.Equal(t, 2, proxySvc.Calls()) require.Equal(t, 2, events.Calls()) require.Equal(t, 2, publicTLS.calls) require.Equal(t, domain.EventConfigReload, events.Type()) } func TestReloadCoordinator_SerializesOverlappingReloadRequests(t *testing.T) { ctx := context.Background() v := viper.New() v.Set("server.gordon_domain", "app.example.com") v.Set("server.registry_port", 5000) v.Set("server.max_proxy_body_size", "2MB") v.Set("server.max_blob_chunk_size", "3MB") v.Set("server.max_proxy_response_size", "4MB") v.Set("server.max_concurrent_connections", 11) started := make(chan struct{}) release := make(chan struct{}) var once sync.Once reloadSvc := &reloadRecorder{onReload: func() { once.Do(func() { close(started) }) <-release }} proxySvc := &proxyRecorder{} coord := newReloadCoordinator(v, reloadSvc, proxySvc, nil, nil, nil, nil, zerowrap.Default()) coord.debounce = 20 * time.Millisecond firstDone := make(chan struct{}) go func() { _ = coord.Trigger(ctx) close(firstDone) }() select { case <-started: case <-time.After(2 * time.Second): t.Fatal("first reload did not start") } secondDone := make(chan struct{}) go func() { _ = coord.Trigger(ctx) close(secondDone) }() select { case <-secondDone: t.Fatal("second reload finished before the first was released") case <-time.After(75 * time.Millisecond): } require.Equal(t, 1, reloadSvc.Calls()) close(release) select { case <-firstDone: case <-time.After(2 * time.Second): t.Fatal("first reload did not finish") } select { case <-secondDone: case <-time.After(2 * time.Second): t.Fatal("second reload did not finish") } require.Eventually(t, func() bool { return proxySvc.Calls() == 2 }, time.Second, 5*time.Millisecond) require.Never(t, func() bool { return proxySvc.Calls() > 2 }, 50*time.Millisecond, 5*time.Millisecond) assert.Equal(t, 2, proxySvc.Calls()) }