1package config
2
3import (
4	"errors"
5	"os"
6	"testing"
7	"time"
8
9	"github.com/stretchr/testify/require"
10	"gitlab.com/gitlab-org/gitaly/v14/internal/gitaly/config"
11	"gitlab.com/gitlab-org/gitaly/v14/internal/gitaly/config/log"
12	"gitlab.com/gitlab-org/gitaly/v14/internal/gitaly/config/prometheus"
13	"gitlab.com/gitlab-org/gitaly/v14/internal/gitaly/config/sentry"
14)
15
16func TestConfigValidation(t *testing.T) {
17	vs1Nodes := []*Node{
18		{Storage: "internal-1.0", Address: "localhost:23456", Token: "secret-token-1"},
19		{Storage: "internal-2.0", Address: "localhost:23457", Token: "secret-token-1"},
20		{Storage: "internal-3.0", Address: "localhost:23458", Token: "secret-token-1"},
21	}
22
23	vs2Nodes := []*Node{
24		// storage can have same name as storage in another virtual storage, but all addresses must be unique
25		{Storage: "internal-1.0", Address: "localhost:33456", Token: "secret-token-2"},
26		{Storage: "internal-2.1", Address: "localhost:33457", Token: "secret-token-2"},
27		{Storage: "internal-3.1", Address: "localhost:33458", Token: "secret-token-2"},
28	}
29
30	testCases := []struct {
31		desc         string
32		changeConfig func(*Config)
33		errMsg       string
34	}{
35		{
36			desc:         "Valid config with ListenAddr",
37			changeConfig: func(*Config) {},
38		},
39		{
40			desc: "Valid config with local elector",
41			changeConfig: func(cfg *Config) {
42				cfg.Failover.ElectionStrategy = ElectionStrategyLocal
43			},
44		},
45		{
46			desc: "Valid config with per repository elector",
47			changeConfig: func(cfg *Config) {
48				cfg.Failover.ElectionStrategy = ElectionStrategyPerRepository
49			},
50		},
51		{
52			desc: "Invalid election strategy",
53			changeConfig: func(cfg *Config) {
54				cfg.Failover.ElectionStrategy = "invalid-strategy"
55			},
56			errMsg: `invalid election strategy: "invalid-strategy"`,
57		},
58		{
59			desc: "Valid config with TLSListenAddr",
60			changeConfig: func(cfg *Config) {
61				cfg.ListenAddr = ""
62				cfg.TLSListenAddr = "tls://localhost:4321"
63			},
64		},
65		{
66			desc: "Valid config with SocketPath",
67			changeConfig: func(cfg *Config) {
68				cfg.ListenAddr = ""
69				cfg.SocketPath = "/tmp/praefect.socket"
70			},
71		},
72		{
73			desc: "Invalid replication batch size",
74			changeConfig: func(cfg *Config) {
75				cfg.Replication = Replication{BatchSize: 0}
76			},
77			errMsg: "replication batch size was 0 but must be >=1",
78		},
79		{
80			desc: "No ListenAddr or SocketPath or TLSListenAddr",
81			changeConfig: func(cfg *Config) {
82				cfg.ListenAddr = ""
83			},
84			errMsg: "no listen address or socket path configured",
85		},
86		{
87			desc: "No virtual storages",
88			changeConfig: func(cfg *Config) {
89				cfg.VirtualStorages = nil
90			},
91			errMsg: "no virtual storages configured",
92		},
93		{
94			desc: "duplicate storage",
95			changeConfig: func(cfg *Config) {
96				cfg.VirtualStorages = []*VirtualStorage{
97					{
98						Name: "default",
99						Nodes: append(vs1Nodes, &Node{
100							Storage: vs1Nodes[0].Storage,
101							Address: vs1Nodes[1].Address,
102						}),
103					},
104				}
105			},
106			errMsg: `virtual storage "default": internal gitaly storages are not unique`,
107		},
108		{
109			desc: "Node storage has no name",
110			changeConfig: func(cfg *Config) {
111				cfg.VirtualStorages = []*VirtualStorage{
112					{
113						Name: "default",
114						Nodes: []*Node{
115							{
116								Storage: "",
117								Address: "localhost:23456",
118								Token:   "secret-token-1",
119							},
120						},
121					},
122				}
123			},
124			errMsg: `virtual storage "default": all gitaly nodes must have a storage`,
125		},
126		{
127			desc: "Node storage has no address",
128			changeConfig: func(cfg *Config) {
129				cfg.VirtualStorages = []*VirtualStorage{
130					{
131						Name: "default",
132						Nodes: []*Node{
133							{
134								Storage: "internal",
135								Address: "",
136								Token:   "secret-token-1",
137							},
138						},
139					},
140				}
141			},
142			errMsg: `virtual storage "default": all gitaly nodes must have an address`,
143		},
144		{
145			desc: "Virtual storage has no name",
146			changeConfig: func(cfg *Config) {
147				cfg.VirtualStorages = []*VirtualStorage{
148					{Name: "", Nodes: vs1Nodes},
149				}
150			},
151			errMsg: `virtual storages must have a name`,
152		},
153		{
154			desc: "Virtual storage not unique",
155			changeConfig: func(cfg *Config) {
156				cfg.VirtualStorages = []*VirtualStorage{
157					{Name: "default", Nodes: vs1Nodes},
158					{Name: "default", Nodes: vs2Nodes},
159				}
160			},
161			errMsg: `virtual storage "default": virtual storages must have unique names`,
162		},
163		{
164			desc: "Virtual storage has no nodes",
165			changeConfig: func(cfg *Config) {
166				cfg.VirtualStorages = []*VirtualStorage{
167					{Name: "default", Nodes: vs1Nodes},
168					{Name: "secondary", Nodes: nil},
169				}
170			},
171			errMsg: `virtual storage "secondary": no primary gitaly backends configured`,
172		},
173		{
174			desc: "Node storage has address duplicate",
175			changeConfig: func(cfg *Config) {
176				cfg.VirtualStorages = []*VirtualStorage{
177					{Name: "default", Nodes: vs1Nodes},
178					{Name: "secondary", Nodes: append(vs2Nodes, vs1Nodes[1])},
179				}
180			},
181			errMsg: `multiple storages have the same address`,
182		},
183		{
184			desc: "default replication factor too high",
185			changeConfig: func(cfg *Config) {
186				cfg.VirtualStorages = []*VirtualStorage{
187					{
188						Name:                     "default",
189						DefaultReplicationFactor: 2,
190						Nodes: []*Node{
191							{
192								Storage: "storage-1",
193								Address: "localhost:23456",
194							},
195						},
196					},
197				}
198			},
199			errMsg: `virtual storage "default" has a default replication factor (2) which is higher than the number of storages (1)`,
200		},
201		{
202			desc: "repositories_cleanup minimal duration is too low",
203			changeConfig: func(cfg *Config) {
204				cfg.RepositoriesCleanup.CheckInterval = config.Duration(minimalSyncCheckInterval - time.Nanosecond)
205			},
206			errMsg: `repositories_cleanup.check_interval is less then 1m0s, which could lead to a database performance problem`,
207		},
208		{
209			desc: "repositories_cleanup minimal duration is too low",
210			changeConfig: func(cfg *Config) {
211				cfg.RepositoriesCleanup.RunInterval = config.Duration(minimalSyncRunInterval - time.Nanosecond)
212			},
213			errMsg: `repositories_cleanup.run_interval is less then 1m0s, which could lead to a database performance problem`,
214		},
215	}
216
217	for _, tc := range testCases {
218		t.Run(tc.desc, func(t *testing.T) {
219			config := Config{
220				ListenAddr:  "localhost:1234",
221				Replication: DefaultReplicationConfig(),
222				VirtualStorages: []*VirtualStorage{
223					{Name: "default", Nodes: vs1Nodes},
224					{Name: "secondary", Nodes: vs2Nodes},
225				},
226				Failover:            Failover{ElectionStrategy: ElectionStrategySQL},
227				RepositoriesCleanup: DefaultRepositoriesCleanup(),
228			}
229
230			tc.changeConfig(&config)
231
232			err := config.Validate()
233			if tc.errMsg == "" {
234				require.NoError(t, err)
235				return
236			}
237
238			require.Error(t, err)
239			require.Contains(t, err.Error(), tc.errMsg)
240		})
241	}
242}
243
244func TestConfigParsing(t *testing.T) {
245	testCases := []struct {
246		desc        string
247		filePath    string
248		expected    Config
249		expectedErr error
250	}{
251		{
252			desc:     "check all configuration values",
253			filePath: "testdata/config.toml",
254			expected: Config{
255				TLSListenAddr: "0.0.0.0:2306",
256				TLS: config.TLS{
257					CertPath: "/home/git/cert.cert",
258					KeyPath:  "/home/git/key.pem",
259				},
260				Logging: log.Config{
261					Level:  "info",
262					Format: "json",
263				},
264				Sentry: sentry.Config{
265					DSN:         "abcd123",
266					Environment: "production",
267				},
268				VirtualStorages: []*VirtualStorage{
269					{
270						Name:                     "praefect",
271						DefaultReplicationFactor: 2,
272						Nodes: []*Node{
273							{
274								Address: "tcp://gitaly-internal-1.example.com",
275								Storage: "praefect-internal-1",
276							},
277							{
278								Address: "tcp://gitaly-internal-2.example.com",
279								Storage: "praefect-internal-2",
280							},
281							{
282								Address: "tcp://gitaly-internal-3.example.com",
283								Storage: "praefect-internal-3",
284							},
285						},
286					},
287				},
288				Prometheus: prometheus.Config{
289					ScrapeTimeout:      time.Second,
290					GRPCLatencyBuckets: []float64{0.1, 0.2, 0.3},
291				},
292				DB: DB{
293					Host:        "1.2.3.4",
294					Port:        5432,
295					User:        "praefect",
296					Password:    "db-secret",
297					DBName:      "praefect_production",
298					SSLMode:     "require",
299					SSLCert:     "/path/to/cert",
300					SSLKey:      "/path/to/key",
301					SSLRootCert: "/path/to/root-cert",
302					SessionPooled: DBConnection{
303						Host:        "2.3.4.5",
304						Port:        6432,
305						User:        "praefect_sp",
306						Password:    "db-secret-sp",
307						DBName:      "praefect_production_sp",
308						SSLMode:     "prefer",
309						SSLCert:     "/path/to/sp/cert",
310						SSLKey:      "/path/to/sp/key",
311						SSLRootCert: "/path/to/sp/root-cert",
312					},
313				},
314				MemoryQueueEnabled:  true,
315				GracefulStopTimeout: config.Duration(30 * time.Second),
316				Reconciliation: Reconciliation{
317					SchedulingInterval: config.Duration(time.Minute),
318					HistogramBuckets:   []float64{1, 2, 3, 4, 5},
319				},
320				Replication: Replication{BatchSize: 1, ParallelStorageProcessingWorkers: 2},
321				Failover: Failover{
322					Enabled:                  true,
323					ElectionStrategy:         ElectionStrategyPerRepository,
324					ErrorThresholdWindow:     config.Duration(20 * time.Second),
325					WriteErrorThresholdCount: 1500,
326					ReadErrorThresholdCount:  100,
327					BootstrapInterval:        config.Duration(1 * time.Second),
328					MonitorInterval:          config.Duration(3 * time.Second),
329				},
330				RepositoriesCleanup: RepositoriesCleanup{
331					CheckInterval:       config.Duration(time.Second),
332					RunInterval:         config.Duration(3 * time.Second),
333					RepositoriesInBatch: 10,
334				},
335			},
336		},
337		{
338			desc:     "overwriting default values in the config",
339			filePath: "testdata/config.overwritedefaults.toml",
340			expected: Config{
341				GracefulStopTimeout: config.Duration(time.Minute),
342				Reconciliation: Reconciliation{
343					SchedulingInterval: 0,
344					HistogramBuckets:   []float64{1, 2, 3, 4, 5},
345				},
346				Prometheus:  prometheus.DefaultConfig(),
347				Replication: Replication{BatchSize: 1, ParallelStorageProcessingWorkers: 2},
348				Failover: Failover{
349					Enabled:           false,
350					ElectionStrategy:  "local",
351					BootstrapInterval: config.Duration(5 * time.Second),
352					MonitorInterval:   config.Duration(10 * time.Second),
353				},
354				RepositoriesCleanup: RepositoriesCleanup{
355					CheckInterval:       config.Duration(time.Second),
356					RunInterval:         config.Duration(4 * time.Second),
357					RepositoriesInBatch: 11,
358				},
359			},
360		},
361		{
362			desc:     "empty config yields default values",
363			filePath: "testdata/config.empty.toml",
364			expected: Config{
365				GracefulStopTimeout: config.Duration(time.Minute),
366				Prometheus:          prometheus.DefaultConfig(),
367				Reconciliation:      DefaultReconciliationConfig(),
368				Replication:         DefaultReplicationConfig(),
369				Failover: Failover{
370					Enabled:           true,
371					ElectionStrategy:  ElectionStrategyPerRepository,
372					BootstrapInterval: config.Duration(time.Second),
373					MonitorInterval:   config.Duration(3 * time.Second),
374				},
375				RepositoriesCleanup: RepositoriesCleanup{
376					CheckInterval:       config.Duration(30 * time.Minute),
377					RunInterval:         config.Duration(24 * time.Hour),
378					RepositoriesInBatch: 16,
379				},
380			},
381		},
382		{
383			desc:        "config file does not exist",
384			filePath:    "testdata/config.invalid-path.toml",
385			expectedErr: os.ErrNotExist,
386		},
387	}
388
389	for _, tc := range testCases {
390		t.Run(tc.desc, func(t *testing.T) {
391			cfg, err := FromFile(tc.filePath)
392			require.True(t, errors.Is(err, tc.expectedErr), "actual error: %v", err)
393			require.Equal(t, tc.expected, cfg)
394		})
395	}
396}
397
398func TestVirtualStorageNames(t *testing.T) {
399	conf := Config{VirtualStorages: []*VirtualStorage{{Name: "praefect-1"}, {Name: "praefect-2"}}}
400	require.Equal(t, []string{"praefect-1", "praefect-2"}, conf.VirtualStorageNames())
401}
402
403func TestStorageNames(t *testing.T) {
404	conf := Config{
405		VirtualStorages: []*VirtualStorage{
406			{Name: "virtual-storage-1", Nodes: []*Node{{Storage: "gitaly-1"}, {Storage: "gitaly-2"}}},
407			{Name: "virtual-storage-2", Nodes: []*Node{{Storage: "gitaly-3"}, {Storage: "gitaly-4"}}},
408		},
409	}
410	require.Equal(t, map[string][]string{
411		"virtual-storage-1": {"gitaly-1", "gitaly-2"},
412		"virtual-storage-2": {"gitaly-3", "gitaly-4"},
413	}, conf.StorageNames())
414}
415
416func TestToPQString(t *testing.T) {
417	testCases := []struct {
418		desc   string
419		in     DB
420		direct bool
421		out    string
422	}{
423		{desc: "empty", in: DB{}, out: "binary_parameters=yes"},
424		{
425			desc: "proxy connection",
426			in: DB{
427				Host:        "1.2.3.4",
428				Port:        2345,
429				User:        "praefect-user",
430				Password:    "secret",
431				DBName:      "praefect_production",
432				SSLMode:     "require",
433				SSLCert:     "/path/to/cert",
434				SSLKey:      "/path/to/key",
435				SSLRootCert: "/path/to/root-cert",
436			},
437			direct: false,
438			out:    `port=2345 host=1.2.3.4 user=praefect-user password=secret dbname=praefect_production sslmode=require sslcert=/path/to/cert sslkey=/path/to/key sslrootcert=/path/to/root-cert binary_parameters=yes`,
439		},
440		{
441			desc: "direct connection with different host and port",
442			in: DB{
443				User:        "praefect-user",
444				Password:    "secret",
445				DBName:      "praefect_production",
446				SSLMode:     "require",
447				SSLCert:     "/path/to/cert",
448				SSLKey:      "/path/to/key",
449				SSLRootCert: "/path/to/root-cert",
450				SessionPooled: DBConnection{
451					Host: "1.2.3.4",
452					Port: 2345,
453				},
454			},
455			direct: true,
456			out:    `port=2345 host=1.2.3.4 user=praefect-user password=secret dbname=praefect_production sslmode=require sslcert=/path/to/cert sslkey=/path/to/key sslrootcert=/path/to/root-cert binary_parameters=yes`,
457		},
458		{
459			desc: "direct connection with dbname",
460			in: DB{
461				Host:        "1.2.3.4",
462				Port:        2345,
463				User:        "praefect-user",
464				Password:    "secret",
465				DBName:      "praefect_production",
466				SSLMode:     "require",
467				SSLCert:     "/path/to/cert",
468				SSLKey:      "/path/to/key",
469				SSLRootCert: "/path/to/root-cert",
470				SessionPooled: DBConnection{
471					DBName: "praefect_production_sp",
472				},
473			},
474			direct: true,
475			out:    `port=2345 host=1.2.3.4 user=praefect-user password=secret dbname=praefect_production_sp sslmode=require sslcert=/path/to/cert sslkey=/path/to/key sslrootcert=/path/to/root-cert binary_parameters=yes`,
476		},
477		{
478			desc: "direct connection with exactly the same parameters",
479			in: DB{
480				Host:          "1.2.3.4",
481				Port:          2345,
482				User:          "praefect-user",
483				Password:      "secret",
484				DBName:        "praefect_production",
485				SSLMode:       "require",
486				SSLCert:       "/path/to/cert",
487				SSLKey:        "/path/to/key",
488				SSLRootCert:   "/path/to/root-cert",
489				SessionPooled: DBConnection{},
490			},
491			direct: true,
492			out:    `port=2345 host=1.2.3.4 user=praefect-user password=secret dbname=praefect_production sslmode=require sslcert=/path/to/cert sslkey=/path/to/key sslrootcert=/path/to/root-cert binary_parameters=yes`,
493		},
494		{
495			desc: "direct connection with completely different parameters",
496			in: DB{
497				Host:        "1.2.3.4",
498				Port:        2345,
499				User:        "praefect-user",
500				Password:    "secret",
501				DBName:      "praefect_production",
502				SSLMode:     "require",
503				SSLCert:     "/path/to/cert",
504				SSLKey:      "/path/to/key",
505				SSLRootCert: "/path/to/root-cert",
506				SessionPooled: DBConnection{
507					Host:        "2.3.4.5",
508					Port:        6432,
509					User:        "praefect_sp",
510					Password:    "secret-sp",
511					DBName:      "praefect_production_sp",
512					SSLMode:     "prefer",
513					SSLCert:     "/path/to/sp/cert",
514					SSLKey:      "/path/to/sp/key",
515					SSLRootCert: "/path/to/sp/root-cert",
516				},
517			},
518			direct: true,
519			out:    `port=6432 host=2.3.4.5 user=praefect_sp password=secret-sp dbname=praefect_production_sp sslmode=prefer sslcert=/path/to/sp/cert sslkey=/path/to/sp/key sslrootcert=/path/to/sp/root-cert binary_parameters=yes`,
520		},
521		{
522			desc: "with spaces and quotes",
523			in: DB{
524				Password: "secret foo'bar",
525			},
526			out: `password=secret\ foo\'bar binary_parameters=yes`,
527		},
528	}
529
530	for _, tc := range testCases {
531		t.Run(tc.desc, func(t *testing.T) {
532			require.Equal(t, tc.out, tc.in.ToPQString(tc.direct))
533		})
534	}
535}
536
537func TestDefaultReplicationFactors(t *testing.T) {
538	for _, tc := range []struct {
539		desc                      string
540		virtualStorages           []*VirtualStorage
541		defaultReplicationFactors map[string]int
542	}{
543		{
544			desc: "replication factors set on some",
545			virtualStorages: []*VirtualStorage{
546				{Name: "virtual-storage-1", DefaultReplicationFactor: 0},
547				{Name: "virtual-storage-2", DefaultReplicationFactor: 1},
548			},
549			defaultReplicationFactors: map[string]int{
550				"virtual-storage-1": 0,
551				"virtual-storage-2": 1,
552			},
553		},
554		{
555			desc:                      "returns always initialized map",
556			virtualStorages:           []*VirtualStorage{},
557			defaultReplicationFactors: map[string]int{},
558		},
559	} {
560		t.Run(tc.desc, func(t *testing.T) {
561			require.Equal(t,
562				tc.defaultReplicationFactors,
563				Config{VirtualStorages: tc.virtualStorages}.DefaultReplicationFactors(),
564			)
565		})
566	}
567}
568
569func TestNeedsSQL(t *testing.T) {
570	testCases := []struct {
571		desc     string
572		config   Config
573		expected bool
574	}{
575		{
576			desc:     "default",
577			config:   Config{},
578			expected: true,
579		},
580		{
581			desc:     "Memory queue enabled",
582			config:   Config{MemoryQueueEnabled: true},
583			expected: false,
584		},
585		{
586			desc:     "Failover enabled with default election strategy",
587			config:   Config{Failover: Failover{Enabled: true}},
588			expected: true,
589		},
590		{
591			desc:     "Failover enabled with SQL election strategy",
592			config:   Config{Failover: Failover{Enabled: true, ElectionStrategy: ElectionStrategyPerRepository}},
593			expected: true,
594		},
595		{
596			desc:     "Both PostgresQL and SQL election strategy enabled",
597			config:   Config{Failover: Failover{Enabled: true, ElectionStrategy: ElectionStrategyPerRepository}},
598			expected: true,
599		},
600		{
601			desc:     "Both PostgresQL and SQL election strategy enabled but failover disabled",
602			config:   Config{Failover: Failover{Enabled: false, ElectionStrategy: ElectionStrategyPerRepository}},
603			expected: true,
604		},
605		{
606			desc:     "Both PostgresQL and per_repository election strategy enabled but failover disabled",
607			config:   Config{Failover: Failover{Enabled: false, ElectionStrategy: ElectionStrategyPerRepository}},
608			expected: true,
609		},
610	}
611
612	for _, tc := range testCases {
613		t.Run(tc.desc, func(t *testing.T) {
614			require.Equal(t, tc.expected, tc.config.NeedsSQL())
615		})
616	}
617}
618