v3.0.0-alpha.1 6fdddb8

run_reload_test.go RawHistoryFolder

1package app
2
3import (
4	"context"
5	"crypto/tls"
6	"errors"
7	"net"
8	"net/http"
9	"strconv"
10	"sync"
11	"testing"
12	"time"
13
14	"github.com/bnema/zerowrap"
15	"github.com/spf13/viper"
16	"github.com/stretchr/testify/assert"
17	"github.com/stretchr/testify/mock"
18	"github.com/stretchr/testify/require"
19
20	"github.com/bnema/gordon/internal/adapters/in/http/registry"
21	trafficadapter "github.com/bnema/gordon/internal/adapters/in/traffic"
22	inmocks "github.com/bnema/gordon/internal/boundaries/in/mocks"
23	"github.com/bnema/gordon/internal/domain"
24	cfgusecase "github.com/bnema/gordon/internal/usecase/config"
25	"github.com/bnema/gordon/internal/usecase/container"
26	"github.com/bnema/gordon/internal/usecase/proxy"
27	traffic "github.com/bnema/gordon/internal/usecase/traffic"
28)
29
30type watchRecorder struct {
31	called   bool
32	onChange func()
33}
34
35func (w *watchRecorder) Watch(ctx context.Context, onChange func()) error {
36	w.called = true
37	w.onChange = onChange
38	return nil
39}
40
41type coordinatorRecorder struct {
42	mu           sync.Mutex
43	triggerCalls int
44	applyCalls   int
45	err          error
46}
47
48func (c *coordinatorRecorder) Trigger(context.Context) error {
49	c.mu.Lock()
50	c.triggerCalls++
51	err := c.err
52	c.mu.Unlock()
53	return err
54}
55
56func (c *coordinatorRecorder) ApplyLoadedConfig(context.Context) error {
57	c.mu.Lock()
58	c.applyCalls++
59	err := c.err
60	c.mu.Unlock()
61	return err
62}
63
64func (c *coordinatorRecorder) TriggerCalls() int {
65	c.mu.Lock()
66	defer c.mu.Unlock()
67	return c.triggerCalls
68}
69
70func (c *coordinatorRecorder) ApplyCalls() int {
71	c.mu.Lock()
72	defer c.mu.Unlock()
73	return c.applyCalls
74}
75
76type reloadRecorder struct {
77	mu       sync.Mutex
78	calls    int
79	err      error
80	onReload func()
81}
82
83func (r *reloadRecorder) Reload(context.Context) error {
84	r.mu.Lock()
85	r.calls++
86	err := r.err
87	onReload := r.onReload
88	r.mu.Unlock()
89
90	if onReload != nil {
91		onReload()
92	}
93
94	return err
95}
96
97func (r *reloadRecorder) Calls() int {
98	r.mu.Lock()
99	defer r.mu.Unlock()
100	return r.calls
101}
102
103type proxyRecorder struct {
104	mu     sync.Mutex
105	calls  int
106	config proxy.Config
107}
108
109func (p *proxyRecorder) UpdateConfig(config proxy.Config) {
110	p.mu.Lock()
111	defer p.mu.Unlock()
112	p.calls++
113	p.config = config
114}
115
116func (p *proxyRecorder) Calls() int {
117	p.mu.Lock()
118	defer p.mu.Unlock()
119	return p.calls
120}
121
122func (p *proxyRecorder) Config() proxy.Config {
123	p.mu.Lock()
124	defer p.mu.Unlock()
125	return p.config
126}
127
128type registryLimitsRecorder struct {
129	mu               sync.Mutex
130	calls            int
131	maxBlobChunkSize int64
132	maxBlobSize      int64
133}
134
135func (r *registryLimitsRecorder) UpdateBlobLimits(maxBlobChunkSize, maxBlobSize int64) {
136	r.mu.Lock()
137	defer r.mu.Unlock()
138	r.calls++
139	r.maxBlobChunkSize = maxBlobChunkSize
140	r.maxBlobSize = maxBlobSize
141}
142
143func (r *registryLimitsRecorder) Calls() int {
144	r.mu.Lock()
145	defer r.mu.Unlock()
146	return r.calls
147}
148
149func (r *registryLimitsRecorder) Limits() (chunk, size int64) {
150	r.mu.Lock()
151	defer r.mu.Unlock()
152	return r.maxBlobChunkSize, r.maxBlobSize
153}
154
155type containerConfigApplyRecorder struct {
156	calls int
157	ctx   context.Context
158	cfg   Config
159}
160
161func (r *containerConfigApplyRecorder) Apply(ctx context.Context, cfg Config) error {
162	r.calls++
163	r.ctx = ctx
164	r.cfg = cfg
165	return nil
166}
167
168type publicTLSReconcileRecorder struct {
169	calls int
170}
171
172func (r *publicTLSReconcileRecorder) Reconcile(context.Context) error {
173	r.calls++
174	return nil
175}
176
177type eventBusRecorder struct {
178	mu        sync.Mutex
179	calls     int
180	eventType domain.EventType
181	payload   any
182	err       error
183}
184
185func (e *eventBusRecorder) Publish(eventType domain.EventType, payload any) error {
186	e.mu.Lock()
187	e.calls++
188	e.eventType = eventType
189	e.payload = payload
190	err := e.err
191	e.mu.Unlock()
192	return err
193}
194
195func (e *eventBusRecorder) Calls() int {
196	e.mu.Lock()
197	defer e.mu.Unlock()
198	return e.calls
199}
200
201func (e *eventBusRecorder) Type() domain.EventType {
202	e.mu.Lock()
203	defer e.mu.Unlock()
204	return e.eventType
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 := &registryLimitsRecorder{}
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	rlChunk, rlSize := registryLimits.Limits()
269	require.Equal(t, int64(6<<20), rlChunk)
270	require.Equal(t, int64(8<<20), rlSize)
271	require.Equal(t, domain.EventConfigReload, events.Type())
272	require.Equal(t, "reload.example.com", containerCfg.cfg.Server.GordonDomain)
273	require.Equal(t, []string{"old.example.com"}, containerCfg.cfg.Server.LegacyRegistryDomains)
274	require.Equal(t, []string{"docker.io"}, containerCfg.cfg.Images.AllowedRegistries)
275	require.Equal(t, proxy.Config{
276		RegistryDomain:     "reload.example.com",
277		RegistryPort:       5000,
278		MaxBodySize:        5 << 20,
279		MaxResponseSize:    7 << 20,
280		MaxConcurrentConns: 99,
281	}, proxySvc.Config())
282}
283
284// newTestServices wires the serialized traffic publisher the reload hook
285// requires. Tests that omit app state still exercise the graph apply.
286func newTestServices(svc *services) *services {
287	svc.appTrafficPublisher = newAppTrafficPublisher(svc, Config{})
288	return svc
289}
290
291func TestServiceInit_ReloadRegistersCustomTLSMuxHTTPSFallback(t *testing.T) {
292	ctx := context.Background()
293	v := viper.New()
294	v.Set("server.gordon_domain", "reload.example.com")
295	v.Set("server.registry_port", 5000)
296
297	configSvc := cfgusecase.NewService(v, nil)
298	require.NoError(t, configSvc.Load(ctx))
299	manager := trafficadapter.NewManager()
300	defer func() { require.NoError(t, manager.Shutdown(ctx)) }()
301	httpsHandler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
302		_, _ = w.Write([]byte("reload secure"))
303	})
304	pkiSvc := newTestPKIService(t)
305
306	customAddress := freeTCPAddress(t)
307	si := &serviceInit{
308		ctx: ctx,
309		v:   v,
310		cfg: Config{},
311		log: zerowrap.Default(),
312		svc: newTestServices(&services{
313			configSvc:         configSvc,
314			containerSvc:      container.NewService(nil, nil, nil, nil, container.Config{}),
315			internalRegUser:   "gordon",
316			internalRegPass:   "secret",
317			reloadCoordinator: newReloadCoordinator(v, &reloadRecorder{}, &proxyRecorder{}, nil, nil, nil, zerowrap.Default()),
318			trafficManager:    manager,
319			httpsProxyHandler: httpsHandler,
320			pkiSvc:            pkiSvc,
321		}),
322	}
323	si.registerReloadCoordinatorHooks()
324
325	reloadCfg := Config{}
326	reloadCfg.Server.GordonDomain = "reload.example.com"
327	reloadCfg.Server.RegistryPort = 5000
328	reloadCfg.Server.TLSPort = freeTCPPort(t)
329	reloadCfg.EntryPoints = map[string]traffic.EntryPointConfig{
330		"custom-secure": {Address: customAddress, Protocol: domain.EntryPointProtocolTLSMux},
331	}
332
333	require.NoError(t, si.svc.reloadCoordinator.applyContainerConfig(ctx, reloadCfg))
334	managementCert, err := pkiSvc.GetCertificate(&tls.ClientHelloInfo{ServerName: "reload.example.com"})
335	require.NoError(t, err)
336	require.NotNil(t, managementCert)
337
338	_, portValue, err := net.SplitHostPort(customAddress)
339	require.NoError(t, err)
340	port, err := strconv.Atoi(portValue)
341	require.NoError(t, err)
342	body := httpsGetForTest(t, port, "app.example.com")
343	require.Contains(t, body, "reload secure")
344}
345
346func TestServiceInit_ReloadUpdatesManagementHosts(t *testing.T) {
347	ctx := t.Context()
348	v := viper.New()
349	v.Set("server.gordon_domain", "old.example.com")
350	v.Set("server.registry_port", 5000)
351
352	configSvc := cfgusecase.NewService(v, nil)
353	require.NoError(t, configSvc.Load(ctx))
354	publicTLS := inmocks.NewMockPublicTLSService(t)
355	publicTLS.EXPECT().SetAdditionalHosts(mock.Anything, []string{"new.example.com"}).Once()
356
357	si := &serviceInit{
358		ctx: ctx,
359		v:   v,
360		cfg: Config{},
361		log: zerowrap.Default(),
362		svc: newTestServices(&services{
363			configSvc:         configSvc,
364			containerSvc:      container.NewService(nil, nil, nil, nil, container.Config{}),
365			internalRegUser:   "gordon",
366			internalRegPass:   "secret",
367			reloadCoordinator: newReloadCoordinator(v, &reloadRecorder{}, &proxyRecorder{}, nil, nil, publicTLS, zerowrap.Default()),
368			publicTLSSvc:      publicTLS,
369		}),
370	}
371	si.registerReloadCoordinatorHooks()
372
373	reloadCfg := Config{}
374	reloadCfg.Server.GordonDomain = "new.example.com"
375	reloadCfg.Server.RegistryPort = 5000
376
377	require.NoError(t, si.svc.reloadCoordinator.applyContainerConfig(ctx, reloadCfg))
378}
379
380func TestServiceInit_RegisterReloadCoordinatorHooks_WiresContainerConfigApplier(t *testing.T) {
381	ctx := context.Background()
382	v := viper.New()
383	v.Set("server.gordon_domain", "reload.example.com")
384	v.Set("server.registry_port", 5000)
385
386	configSvc := cfgusecase.NewService(v, nil)
387	require.NoError(t, configSvc.Load(ctx))
388
389	si := &serviceInit{
390		ctx: ctx,
391		v:   v,
392		cfg: Config{},
393		log: zerowrap.Default(),
394		svc: newTestServices(&services{
395			configSvc:         configSvc,
396			containerSvc:      container.NewService(nil, nil, nil, nil, container.Config{}),
397			internalRegUser:   "gordon",
398			internalRegPass:   "secret",
399			reloadCoordinator: newReloadCoordinator(v, &reloadRecorder{}, &proxyRecorder{}, nil, nil, nil, zerowrap.Default()),
400		}),
401	}
402
403	si.registerReloadCoordinatorHooks()
404	require.NotNil(t, si.svc.reloadCoordinator.applyContainerConfig)
405
406	reloadCfg := Config{}
407	reloadCfg.Server.GordonDomain = "reload.example.com"
408	reloadCfg.Server.RegistryPort = 5000
409	reloadCfg.Images.AllowedRegistries = []string{"docker.io"}
410
411	require.NoError(t, si.svc.reloadCoordinator.applyContainerConfig(ctx, reloadCfg))
412}
413
414func TestServiceInit_RegisterReloadCoordinatorHooks_AppliesTraffic(t *testing.T) {
415	ctx := context.Background()
416	v := viper.New()
417	v.Set("server.gordon_domain", "reload.example.com")
418	v.Set("server.registry_port", 5000)
419
420	configSvc := cfgusecase.NewService(v, nil)
421	require.NoError(t, configSvc.Load(ctx))
422	manager := trafficadapter.NewManager()
423	defer func() { require.NoError(t, manager.Shutdown(ctx)) }()
424
425	si := &serviceInit{
426		ctx: ctx,
427		v:   v,
428		cfg: Config{},
429		log: zerowrap.Default(),
430		svc: newTestServices(&services{
431			configSvc:         configSvc,
432			containerSvc:      container.NewService(nil, nil, nil, nil, container.Config{}),
433			internalRegUser:   "gordon",
434			internalRegPass:   "secret",
435			reloadCoordinator: newReloadCoordinator(v, &reloadRecorder{}, &proxyRecorder{}, nil, nil, nil, zerowrap.Default()),
436			trafficManager:    manager,
437		}),
438	}
439	si.registerReloadCoordinatorHooks()
440
441	reloadCfg := Config{}
442	reloadCfg.Server.GordonDomain = "reload.example.com"
443	reloadCfg.Server.RegistryPort = 5000
444	reloadCfg.EntryPoints = map[string]traffic.EntryPointConfig{"game": {Address: freeTCPAddress(t), Protocol: domain.EntryPointProtocolTCP}}
445	reloadCfg.NetworkServices = []traffic.NetworkServiceConfig{{Name: "game", Ports: []traffic.PortConfig{{Name: "game", Container: 28015, Protocol: domain.NetworkProtocolTCP}}}}
446	reloadCfg.Traffic.TCP.Routers = []traffic.RouterConfig{{Name: "game", EntryPoint: "game", Service: "network_service:game:game"}}
447
448	require.NoError(t, si.svc.reloadCoordinator.applyContainerConfig(ctx, reloadCfg))
449	assert.NotEmpty(t, manager.Status().EntryPoints)
450}
451
452func TestWaitForCoreProxyReady_WaitsWithoutPublishingEmptyTraffic(t *testing.T) {
453	ctx := context.Background()
454	manager := trafficadapter.NewManager()
455	defer func() { require.NoError(t, manager.Shutdown(ctx)) }()
456
457	ready := make(chan struct{})
458	close(ready)
459	require.NoError(t, waitForCoreProxyReady(ctx, ready, ready, nil))
460	assert.Empty(t, manager.Status().EntryPoints)
461}
462
463func TestWaitForCoreProxyReady_ObservesContextCancellation(t *testing.T) {
464	ctx, cancel := context.WithCancel(context.Background())
465	cancel()
466
467	// Neither readiness channel ever closes: the canceled startup context must
468	// unblock the wait rather than hang on a listener that will never bind.
469	err := waitForCoreProxyReady(ctx, make(chan struct{}), make(chan struct{}), make(chan error))
470	require.ErrorIs(t, err, context.Canceled)
471}
472
473func TestReloadCoordinator_StopCancelsOwnedTrailingReloadLifecycle(t *testing.T) {
474	ctx := context.Background()
475	v := viper.New()
476	v.Set("server.gordon_domain", "lifecycle.example.com")
477	v.Set("server.registry_port", 5000)
478
479	reloadSvc := &reloadRecorder{}
480	proxySvc := &proxyRecorder{}
481	coord := newReloadCoordinator(v, reloadSvc, proxySvc, nil, nil, nil, zerowrap.Default())
482	// Keep the trailing timer pending so Stop is the only thing that can tear
483	// the coordinator-owned lifecycle down.
484	coord.debounce = time.Hour
485
486	require.NoError(t, coord.Trigger(ctx)) // immediate apply records lastRun
487	require.NoError(t, coord.Trigger(ctx)) // coalesced into the trailing timer
488
489	coord.mu.Lock()
490	lifecycleCtx := coord.lifecycleCtx
491	coord.mu.Unlock()
492	require.NotNil(t, lifecycleCtx, "trailing reload must own a lifecycle context")
493	require.NoError(t, lifecycleCtx.Err())
494
495	coord.Stop()
496	require.ErrorIs(t, lifecycleCtx.Err(), context.Canceled)
497	require.Equal(t, 1, proxySvc.Calls(), "stopped coordinator must not apply the trailing reload")
498}
499
500func TestReloadCoordinator_DebouncesRepeatedWatchCallbacks(t *testing.T) {
501	ctx := context.Background()
502	v := viper.New()
503	v.Set("server.gordon_domain", "new.example.com")
504	v.Set("server.registry_port", 5000)
505	v.Set("server.max_proxy_body_size", "5MB")
506	v.Set("server.max_blob_chunk_size", "6MB")
507	v.Set("server.max_blob_size", "8MB")
508	v.Set("server.max_proxy_response_size", "7MB")
509	v.Set("server.max_concurrent_connections", 99)
510
511	reloadSvc := &reloadRecorder{}
512	proxySvc := &proxyRecorder{}
513	events := &eventBusRecorder{}
514
515	registryLimits := &registryLimitsRecorder{}
516	coord := newReloadCoordinator(v, reloadSvc, proxySvc, registryLimits, events, nil, zerowrap.Default())
517	coord.debounce = 20 * time.Millisecond
518	require.NoError(t, coord.Trigger(ctx))
519	require.NoError(t, coord.Trigger(ctx))
520	require.NoError(t, coord.Trigger(ctx))
521
522	// Observe the trailing reload through the recorders' own locks instead of
523	// locking the coordinator: the reload event is published last, so waiting
524	// for all recorders proves the trailing reload finished applying.
525	require.Eventually(t, func() bool {
526		return reloadSvc.Calls() == 2 && proxySvc.Calls() == 2 &&
527			events.Calls() == 2 && registryLimits.Calls() == 2
528	}, time.Second, 5*time.Millisecond)
529	// No further reload may fire: the debounce window is over.
530	require.Never(t, func() bool {
531		return proxySvc.Calls() > 2 || events.Calls() > 2
532	}, 50*time.Millisecond, 5*time.Millisecond)
533	require.Equal(t, 2, proxySvc.Calls())
534	require.Equal(t, 2, events.Calls())
535	require.Equal(t, 2, registryLimits.Calls())
536	rlChunk, rlSize := registryLimits.Limits()
537	require.Equal(t, int64(6<<20), rlChunk)
538	require.Equal(t, int64(8<<20), rlSize)
539	require.Equal(t, domain.EventConfigReload, events.Type())
540	require.Equal(t, proxy.Config{
541		RegistryDomain:     "new.example.com",
542		RegistryPort:       5000,
543		MaxBodySize:        5 << 20,
544		MaxResponseSize:    7 << 20,
545		MaxConcurrentConns: 99,
546	}, proxySvc.Config())
547}
548
549func TestSetupConfigHotReload_CoalescesRepeatedWatcherCallbacks(t *testing.T) {
550	ctx := context.Background()
551	v := viper.New()
552	v.Set("server.gordon_domain", "watch.example.com")
553	v.Set("server.registry_port", 5000)
554
555	configSvc := &watchRecorder{}
556	reloadSvc := &reloadRecorder{}
557	proxySvc := &proxyRecorder{}
558	coord := newReloadCoordinator(v, reloadSvc, proxySvc, nil, nil, nil, zerowrap.Default())
559	coord.debounce = 20 * time.Millisecond
560	defer coord.Stop()
561
562	require.NoError(t, setupConfigHotReload(ctx, configSvc, coord))
563	require.NotNil(t, configSvc.onChange)
564
565	// fsnotify delivers a burst of events for a single edit; each callback
566	// must not apply the config on its own.
567	configSvc.onChange()
568	configSvc.onChange()
569	configSvc.onChange()
570
571	require.Eventually(t, func() bool { return proxySvc.Calls() == 2 }, time.Second, 5*time.Millisecond)
572	require.Never(t, func() bool { return proxySvc.Calls() > 2 }, 50*time.Millisecond, 5*time.Millisecond)
573	// ApplyLoadedConfig applies what the watcher already loaded: the trailing
574	// apply must not read the config again.
575	require.Zero(t, reloadSvc.Calls())
576	require.Equal(t, "watch.example.com", proxySvc.Config().RegistryDomain)
577}
578
579func TestReloadCoordinator_TrailingReloadAppliesFinalState(t *testing.T) {
580	ctx := context.Background()
581	v := viper.New()
582	v.Set("server.gordon_domain", "initial.example.com")
583	v.Set("server.registry_port", 5000)
584
585	reloadSvc := &reloadRecorder{}
586	proxySvc := &proxyRecorder{}
587	coord := newReloadCoordinator(v, reloadSvc, proxySvc, nil, nil, nil, zerowrap.Default())
588	coord.debounce = 20 * time.Millisecond
589
590	require.NoError(t, coord.Trigger(ctx))
591	v.Set("server.gordon_domain", "intermediate.example.com")
592	require.NoError(t, coord.Trigger(ctx))
593	v.Set("server.gordon_domain", "final.example.com")
594	require.NoError(t, coord.Trigger(ctx))
595
596	// The trailing reload is the only writer of the final state: wait for
597	// the observable outcome instead of locking the coordinator.
598	require.Eventually(t, func() bool {
599		return proxySvc.Config().RegistryDomain == "final.example.com"
600	}, time.Second, 5*time.Millisecond)
601}
602
603func TestReloadCoordinator_InvalidReloadKeepsActiveConfigThenAcceptsValidChange(t *testing.T) {
604	ctx := context.Background()
605	v := viper.New()
606	v.Set("server.gordon_domain", "active.example.com")
607	v.Set("server.registry_port", 5000)
608
609	reloadSvc := &reloadRecorder{}
610	proxySvc := &proxyRecorder{}
611	coord := newReloadCoordinator(v, reloadSvc, proxySvc, nil, nil, nil, zerowrap.Default())
612	coord.debounce = 10 * time.Millisecond
613
614	require.NoError(t, coord.Trigger(ctx))
615	require.Equal(t, "active.example.com", proxySvc.Config().RegistryDomain)
616
617	time.Sleep(coord.debounce)
618	v.Set("server.max_proxy_body_size", "invalid")
619	require.Error(t, coord.Trigger(ctx))
620	require.Equal(t, "active.example.com", proxySvc.Config().RegistryDomain)
621
622	v.Set("server.max_proxy_body_size", "5MB")
623	v.Set("server.gordon_domain", "recovered.example.com")
624	require.NoError(t, coord.Trigger(ctx))
625	require.Equal(t, "recovered.example.com", proxySvc.Config().RegistryDomain)
626}
627
628func TestReloadCoordinator_RetriesImmediatelyAfterFailedReload(t *testing.T) {
629	ctx := context.Background()
630	v := viper.New()
631	v.Set("server.gordon_domain", "retry.example.com")
632	v.Set("server.registry_port", 5000)
633	v.Set("server.max_proxy_body_size", "2MB")
634	v.Set("server.max_blob_chunk_size", "3MB")
635	v.Set("server.max_proxy_response_size", "4MB")
636	v.Set("server.max_concurrent_connections", 11)
637
638	reloadErr := errors.New("reload failed")
639	reloadSvc := &reloadRecorder{err: reloadErr}
640	proxySvc := &proxyRecorder{}
641	coord := newReloadCoordinator(v, reloadSvc, proxySvc, nil, nil, nil, zerowrap.Default())
642
643	err := coord.Trigger(ctx)
644	require.ErrorIs(t, err, reloadErr)
645
646	reloadSvc.mu.Lock()
647	reloadSvc.err = nil
648	reloadSvc.mu.Unlock()
649
650	require.NoError(t, coord.Trigger(ctx))
651	require.Equal(t, 2, reloadSvc.Calls())
652	assert.Equal(t, 1, proxySvc.Calls())
653}
654
655func TestReloadCoordinator_PublishErrorDoesNotAdvanceDebounceState(t *testing.T) {
656	ctx := context.Background()
657	v := viper.New()
658	v.Set("server.gordon_domain", "publish.example.com")
659	v.Set("server.registry_port", 5000)
660	v.Set("server.max_proxy_body_size", "5MB")
661	v.Set("server.max_blob_chunk_size", "6MB")
662	v.Set("server.max_proxy_response_size", "7MB")
663	v.Set("server.max_concurrent_connections", 99)
664
665	publishErr := errors.New("publish failed")
666	reloadSvc := &reloadRecorder{}
667	proxySvc := &proxyRecorder{}
668	events := &eventBusRecorder{err: publishErr}
669	publicTLS := &publicTLSReconcileRecorder{}
670
671	coord := newReloadCoordinator(v, reloadSvc, proxySvc, nil, events, publicTLS, zerowrap.Default())
672
673	err := coord.Trigger(ctx)
674	require.ErrorIs(t, err, publishErr)
675
676	err = coord.Trigger(ctx)
677	require.ErrorIs(t, err, publishErr)
678
679	require.Equal(t, 2, reloadSvc.Calls())
680	require.Equal(t, 2, proxySvc.Calls())
681	require.Equal(t, 2, events.Calls())
682	require.Equal(t, 2, publicTLS.calls)
683	require.Equal(t, domain.EventConfigReload, events.Type())
684}
685
686func TestReloadCoordinator_SerializesOverlappingReloadRequests(t *testing.T) {
687	ctx := context.Background()
688	v := viper.New()
689	v.Set("server.gordon_domain", "app.example.com")
690	v.Set("server.registry_port", 5000)
691	v.Set("server.max_proxy_body_size", "2MB")
692	v.Set("server.max_blob_chunk_size", "3MB")
693	v.Set("server.max_proxy_response_size", "4MB")
694	v.Set("server.max_concurrent_connections", 11)
695
696	started := make(chan struct{})
697	release := make(chan struct{})
698	var once sync.Once
699	reloadSvc := &reloadRecorder{onReload: func() {
700		once.Do(func() { close(started) })
701		<-release
702	}}
703	proxySvc := &proxyRecorder{}
704	coord := newReloadCoordinator(v, reloadSvc, proxySvc, nil, nil, nil, zerowrap.Default())
705	coord.debounce = 20 * time.Millisecond
706
707	firstDone := make(chan struct{})
708	go func() {
709		_ = coord.Trigger(ctx)
710		close(firstDone)
711	}()
712
713	select {
714	case <-started:
715	case <-time.After(2 * time.Second):
716		t.Fatal("first reload did not start")
717	}
718
719	secondDone := make(chan struct{})
720	go func() {
721		_ = coord.Trigger(ctx)
722		close(secondDone)
723	}()
724
725	select {
726	case <-secondDone:
727		t.Fatal("second reload finished before the first was released")
728	case <-time.After(75 * time.Millisecond):
729	}
730
731	require.Equal(t, 1, reloadSvc.Calls())
732	close(release)
733
734	select {
735	case <-firstDone:
736	case <-time.After(2 * time.Second):
737		t.Fatal("first reload did not finish")
738	}
739
740	select {
741	case <-secondDone:
742	case <-time.After(2 * time.Second):
743		t.Fatal("second reload did not finish")
744	}
745
746	require.Eventually(t, func() bool { return proxySvc.Calls() == 2 }, time.Second, 5*time.Millisecond)
747	require.Never(t, func() bool { return proxySvc.Calls() > 2 }, 50*time.Millisecond, 5*time.Millisecond)
748	assert.Equal(t, 2, proxySvc.Calls())
749}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close