v2.31.0 e02f654

run_reload_test.go RawHistoryFolder

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 := &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	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 := &registryLimitsRecorder{}
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}

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