Skip to content

Commit 0b64e2e

Browse files
Igor DrozdovVasilii Iakliushin
authored andcommitted
Merge branch '763-topology-service-config' into 'main'
Add Topology Service configuration types See merge request https://gitlab.com/gitlab-org/gitlab-shell/-/merge_requests/1382 Merged-by: Igor Drozdov <idrozdov@gitlab.com> Approved-by: Igor Drozdov <idrozdov@gitlab.com> Co-authored-by: Vasilii Iakliushin <viakliushin@gitlab.com>
2 parents 746b21f + d6df037 commit 0b64e2e

6 files changed

Lines changed: 333 additions & 0 deletions

File tree

config.yml.example

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,3 +128,35 @@ pat:
128128
enabled: true
129129
# Configure which PAT scopes are allowable to generate using an SSH key
130130
# allowed_scopes: [read_repository]
131+
132+
# Topology Service configuration for GitLab Cells routing.
133+
# This enables routing SSH requests to the appropriate cell in a multi-cell deployment.
134+
# See: https://handbook.gitlab.com/handbook/engineering/architecture/design-documents/cells/topology_service/
135+
#
136+
# topology_service:
137+
# # Enable Topology Service integration
138+
# enabled: false
139+
#
140+
# # gRPC address of the Topology Service
141+
# address: "topology.gitlab.com:443"
142+
#
143+
# # ClassifyType to use when querying for cell routing.
144+
# # Options: first_cell, session_prefix, cell_id
145+
# classify_type: "first_cell"
146+
#
147+
# # Timeout for Topology Service requests. Defaults to 5s.
148+
# timeout: 5s
149+
#
150+
# # TLS configuration for secure connections
151+
# tls:
152+
# # Enable TLS (recommended for production)
153+
# enabled: true
154+
# # Path to CA certificate file for server verification
155+
# ca_file: "/etc/gitlab/ssl/topology-ca.crt"
156+
# # Client certificate for mTLS (if required by Topology Service)
157+
# # cert_file: "/etc/gitlab/ssl/client.crt"
158+
# # key_file: "/etc/gitlab/ssl/client.key"
159+
# # Expected server name for TLS verification
160+
# # server_name: "topology.gitlab.com"
161+
# # Skip TLS verification (development only, not recommended for production)
162+
# # insecure_skip_verify: false

internal/config/config.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
package config
55

66
import (
7+
"fmt"
78
"net/url"
89
"os"
910
"path"
@@ -16,6 +17,7 @@ import (
1617
"gitlab.com/gitlab-org/gitlab-shell/v14/client"
1718
"gitlab.com/gitlab-org/gitlab-shell/v14/internal/gitaly"
1819
"gitlab.com/gitlab-org/gitlab-shell/v14/internal/metrics"
20+
"gitlab.com/gitlab-org/gitlab-shell/v14/internal/topology"
1921
)
2022

2123
const (
@@ -97,6 +99,9 @@ type Config struct {
9799
LFSConfig LFSConfig `yaml:"lfs"`
98100
PATConfig PATConfig `yaml:"pat"`
99101

102+
// TopologyService contains Topology Service client configuration for Cells routing.
103+
TopologyService topology.Config `yaml:"topology_service"`
104+
100105
httpClient *client.HTTPClient
101106
httpClientErr error
102107
httpClientOnce sync.Once
@@ -242,6 +247,10 @@ func newFromFile(path string) (*Config, error) {
242247
cfg.LogFile = filepath.Join(cfg.RootDir, cfg.LogFile)
243248
}
244249

250+
if err := cfg.TopologyService.Validate(); err != nil {
251+
return nil, fmt.Errorf("invalid topology_service config: %w", err)
252+
}
253+
245254
return cfg, nil
246255
}
247256

internal/config/config_test.go

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,3 +104,67 @@ func TestYAMLDuration(t *testing.T) {
104104
})
105105
}
106106
}
107+
108+
func TestTopologyServiceConfig(t *testing.T) {
109+
t.Run("default test config has topology_service disabled", func(t *testing.T) {
110+
testRoot := testhelper.PrepareTestRootDir(t)
111+
cfg, err := NewFromDir(testRoot)
112+
require.NoError(t, err)
113+
require.False(t, cfg.TopologyService.Enabled)
114+
})
115+
116+
t.Run("parses full topology_service configuration from YAML", func(t *testing.T) {
117+
yamlData := `
118+
topology_service:
119+
enabled: true
120+
address: "topology.example.com:443"
121+
classify_type: "first_cell"
122+
timeout: 10s
123+
tls:
124+
enabled: true
125+
ca_file: "/path/to/ca.crt"
126+
cert_file: "/path/to/cert.crt"
127+
key_file: "/path/to/key.pem"
128+
server_name: "topology.example.com"
129+
insecure_skip_verify: true
130+
`
131+
var cfg Config
132+
require.NoError(t, yaml.Unmarshal([]byte(yamlData), &cfg))
133+
134+
ts := cfg.TopologyService
135+
require.True(t, ts.Enabled)
136+
require.Equal(t, "topology.example.com:443", ts.Address)
137+
require.Equal(t, "first_cell", ts.ClassifyType)
138+
require.Equal(t, 10*time.Second, ts.Timeout)
139+
require.True(t, ts.TLS.Enabled)
140+
require.Equal(t, "/path/to/ca.crt", ts.TLS.CAFile)
141+
require.Equal(t, "/path/to/cert.crt", ts.TLS.CertFile)
142+
require.Equal(t, "/path/to/key.pem", ts.TLS.KeyFile)
143+
require.Equal(t, "topology.example.com", ts.TLS.ServerName)
144+
require.True(t, ts.TLS.InsecureSkipVerify)
145+
})
146+
}
147+
148+
func TestTopologyServiceConfigValidation(t *testing.T) {
149+
t.Run("newFromFile rejects invalid topology config", func(t *testing.T) {
150+
// Create a temporary directory with an invalid config
151+
tmpDir := t.TempDir()
152+
configPath := tmpDir + "/config.yml"
153+
secretPath := tmpDir + "/.gitlab_shell_secret"
154+
155+
// Write secret file
156+
require.NoError(t, os.WriteFile(secretPath, []byte("test-secret"), 0o600))
157+
158+
// Write config with enabled topology but missing address
159+
invalidConfig := `
160+
topology_service:
161+
enabled: true
162+
`
163+
require.NoError(t, os.WriteFile(configPath, []byte(invalidConfig), 0o600))
164+
165+
_, err := NewFromDir(tmpDir)
166+
require.Error(t, err)
167+
require.Contains(t, err.Error(), "invalid topology_service config")
168+
require.Contains(t, err.Error(), "address is required")
169+
})
170+
}

internal/testhelper/testdata/testroot/config.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,7 @@ sshd:
22
grace_period: 10
33
client_alive_interval: 1m
44
proxy_header_timeout: 500ms
5+
6+
# Topology service disabled by default in tests
7+
topology_service:
8+
enabled: false

internal/topology/config.go

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
// Package topology provides a client for interacting with the GitLab Cells Topology Service.
2+
//
3+
// The Topology Service is a gRPC server that provides cell routing information for
4+
// requests related to specific records (projects, groups, etc.) in a GitLab Cells
5+
// architecture.
6+
//
7+
// Configuration is done via the topology_service section in config.yml:
8+
//
9+
// topology_service:
10+
// enabled: true
11+
// address: "topology.gitlab.com:443"
12+
// classify_type: "first_cell"
13+
// tls:
14+
// enabled: true
15+
// ca_file: "/path/to/ca.crt"
16+
//
17+
// For more details, see:
18+
// - https://handbook.gitlab.com/handbook/engineering/architecture/design-documents/cells/topology_service/
19+
// - https://gitlab.com/gitlab-org/cells/topology-service
20+
package topology
21+
22+
import (
23+
"errors"
24+
"fmt"
25+
"slices"
26+
"strings"
27+
"time"
28+
)
29+
30+
// ValidClassifyTypes contains the list of valid classify_type values.
31+
// These correspond to the ClassifyType enum in the Topology Service proto.
32+
var ValidClassifyTypes = []string{"first_cell", "session_prefix", "cell_id"}
33+
34+
// DefaultTimeout is the default timeout for Topology Service requests.
35+
const DefaultTimeout = 5 * time.Second
36+
37+
// Config contains Topology Service client configuration settings.
38+
type Config struct {
39+
// Enabled indicates whether Topology Service integration is enabled.
40+
Enabled bool `yaml:"enabled"`
41+
42+
// Address is the gRPC address of the Topology Service (e.g., "topology.gitlab.com:443").
43+
Address string `yaml:"address"`
44+
45+
// ClassifyType specifies which ClassifyType to use when querying the service.
46+
// Valid values: "first_cell", "session_prefix", "cell_id".
47+
// Default: "first_cell" (applied at runtime when empty).
48+
ClassifyType string `yaml:"classify_type"`
49+
50+
// Timeout is the maximum duration to wait for a response from the Topology Service.
51+
// Default: 5s (when zero).
52+
Timeout time.Duration `yaml:"timeout"`
53+
54+
// TLS contains TLS configuration for secure connections.
55+
TLS TLSConfig `yaml:"tls"`
56+
}
57+
58+
// TLSConfig contains TLS settings for the Topology Service connection.
59+
type TLSConfig struct {
60+
// Enabled indicates whether TLS should be used for the connection.
61+
Enabled bool `yaml:"enabled"`
62+
63+
// CAFile is the path to the CA certificate file for server verification.
64+
// If empty, system CA certificates will be used.
65+
CAFile string `yaml:"ca_file"`
66+
67+
// CertFile is the path to the client certificate file (for mTLS).
68+
// Must be provided together with KeyFile.
69+
CertFile string `yaml:"cert_file"`
70+
71+
// KeyFile is the path to the client key file (for mTLS).
72+
// Must be provided together with CertFile.
73+
KeyFile string `yaml:"key_file"`
74+
75+
// ServerName is the expected server name for TLS verification.
76+
// If empty, the hostname from Address will be used.
77+
ServerName string `yaml:"server_name"`
78+
79+
// InsecureSkipVerify skips TLS certificate verification.
80+
// WARNING: This should only be used for development/testing.
81+
InsecureSkipVerify bool `yaml:"insecure_skip_verify"`
82+
}
83+
84+
// Validate validates the Topology Service configuration.
85+
func (c *Config) Validate() error {
86+
if !c.Enabled {
87+
return nil
88+
}
89+
90+
if c.Address == "" {
91+
return errors.New("topology_service.address is required when enabled")
92+
}
93+
94+
if !strings.Contains(c.Address, ":") {
95+
return errors.New("topology_service.address must be in host:port format")
96+
}
97+
98+
if c.ClassifyType != "" && !slices.Contains(ValidClassifyTypes, c.ClassifyType) {
99+
return fmt.Errorf("invalid topology_service.classify_type: %q, must be one of %v", c.ClassifyType, ValidClassifyTypes)
100+
}
101+
102+
if err := c.TLS.Validate(); err != nil {
103+
return fmt.Errorf("topology_service.tls: %w", err)
104+
}
105+
106+
return nil
107+
}
108+
109+
// Validate validates the TLS configuration.
110+
func (c *TLSConfig) Validate() error {
111+
if !c.Enabled {
112+
return nil
113+
}
114+
115+
// Check that both cert and key are provided together for mTLS
116+
hasCert := c.CertFile != ""
117+
hasKey := c.KeyFile != ""
118+
119+
if hasCert != hasKey {
120+
return errors.New("both cert_file and key_file must be provided for mTLS")
121+
}
122+
123+
return nil
124+
}

internal/topology/config_test.go

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
package topology
2+
3+
import (
4+
"testing"
5+
"time"
6+
7+
"github.com/stretchr/testify/require"
8+
)
9+
10+
func TestConfigValidate(t *testing.T) {
11+
t.Run("disabled config is always valid", func(t *testing.T) {
12+
require.NoError(t, (&Config{Enabled: false}).Validate())
13+
require.NoError(t, (&Config{Enabled: false, Address: ""}).Validate())
14+
})
15+
16+
t.Run("enabled config requires address", func(t *testing.T) {
17+
err := (&Config{Enabled: true}).Validate()
18+
require.Error(t, err)
19+
require.Contains(t, err.Error(), "address is required")
20+
})
21+
22+
t.Run("enabled config requires address in host:port format", func(t *testing.T) {
23+
err := (&Config{Enabled: true, Address: "localhost"}).Validate()
24+
require.Error(t, err)
25+
require.Contains(t, err.Error(), "must be in host:port format")
26+
})
27+
28+
t.Run("enabled config with address is valid", func(t *testing.T) {
29+
cfg := &Config{Enabled: true, Address: "localhost:8080"}
30+
require.NoError(t, cfg.Validate())
31+
})
32+
33+
t.Run("enabled config with TLS is valid", func(t *testing.T) {
34+
cfg := &Config{
35+
Enabled: true,
36+
Address: "topology.gitlab.com:443",
37+
TLS: TLSConfig{Enabled: true, CAFile: "/path/to/ca.crt"},
38+
}
39+
require.NoError(t, cfg.Validate())
40+
})
41+
42+
t.Run("invalid classify_type fails", func(t *testing.T) {
43+
cfg := &Config{Enabled: true, Address: "localhost:8080", ClassifyType: "invalid_type"}
44+
err := cfg.Validate()
45+
require.Error(t, err)
46+
require.Contains(t, err.Error(), "invalid topology_service.classify_type")
47+
})
48+
49+
t.Run("valid classify_types succeed", func(t *testing.T) {
50+
for _, ct := range []string{"first_cell", "session_prefix", "cell_id", ""} {
51+
cfg := &Config{Enabled: true, Address: "localhost:8080", ClassifyType: ct}
52+
require.NoError(t, cfg.Validate(), "classify_type=%q should be valid", ct)
53+
}
54+
})
55+
}
56+
57+
func TestTLSConfigValidate(t *testing.T) {
58+
t.Run("disabled TLS is always valid", func(t *testing.T) {
59+
require.NoError(t, (&TLSConfig{Enabled: false}).Validate())
60+
})
61+
62+
t.Run("enabled TLS without CA uses system CAs", func(t *testing.T) {
63+
require.NoError(t, (&TLSConfig{Enabled: true}).Validate())
64+
})
65+
66+
t.Run("mTLS requires both cert and key", func(t *testing.T) {
67+
err := (&TLSConfig{Enabled: true, CertFile: "/cert.crt"}).Validate()
68+
require.Error(t, err)
69+
require.Contains(t, err.Error(), "both cert_file and key_file must be provided")
70+
71+
err = (&TLSConfig{Enabled: true, KeyFile: "/key.pem"}).Validate()
72+
require.Error(t, err)
73+
require.Contains(t, err.Error(), "both cert_file and key_file must be provided")
74+
})
75+
76+
t.Run("mTLS with both cert and key is valid", func(t *testing.T) {
77+
cfg := &TLSConfig{Enabled: true, CertFile: "/cert.crt", KeyFile: "/key.pem"}
78+
require.NoError(t, cfg.Validate())
79+
})
80+
81+
t.Run("full mTLS config is valid", func(t *testing.T) {
82+
cfg := &TLSConfig{
83+
Enabled: true,
84+
CAFile: "/ca.crt",
85+
CertFile: "/cert.crt",
86+
KeyFile: "/key.pem",
87+
ServerName: "topology.gitlab.com",
88+
}
89+
require.NoError(t, cfg.Validate())
90+
})
91+
}
92+
93+
func TestValidClassifyTypes(t *testing.T) {
94+
expected := []string{"first_cell", "session_prefix", "cell_id"}
95+
require.Equal(t, expected, ValidClassifyTypes)
96+
}
97+
98+
func TestDefaultTimeout(t *testing.T) {
99+
require.Equal(t, 5*time.Second, DefaultTimeout)
100+
}

0 commit comments

Comments
 (0)