Skip to content

Commit e08cf47

Browse files
rxbnsimu
andauthored
Add support for custom GitLab SSH endpoint (#330)
Co-authored-by: Simon Gerber <gesimu@gmail.com>
1 parent e6d6789 commit e08cf47

5 files changed

Lines changed: 131 additions & 27 deletions

File tree

docs/modules/ROOT/pages/how-tos/gitlab-connection.adoc

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,16 @@ image::gitlab_settings.png[]
1414

1515
Before any other things can be created we need to specify the Git repository API endpoint first:
1616

17+
[NOTE]
18+
====
19+
`sshEndpoint` is optional. If omitted, the operator uses the same host as `endpoint` for SSH.
20+
Provide a full SSH URL (for example `ssh://gitlab-ssh.example.com`) or just a host name.
21+
====
22+
1723
[source,shell]
1824
....
1925
kubectl -n lieutenant create secret generic lieutenant-secret \
2026
--from-literal endpoint=http://10.144.1.197:8080 \
27+
--from-literal sshEndpoint=ssh://gitlab-ssh.example.com \
2128
--from-literal token=<token>
2229
....

git/gitlab/gitlab.go

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,7 @@ func init() {
2626
manager.Register(&Gitlab{})
2727
}
2828

29-
var (
30-
ListItemsPerPage = 100
31-
)
29+
var ListItemsPerPage = 100
3230

3331
// Gitlab holds the necessary information to communincate with a Gitlab server.
3432
// Each Gitlab instance will handle exactly one project.
@@ -43,7 +41,6 @@ type Gitlab struct {
4341

4442
// Create will create a new Gitlab project
4543
func (g *Gitlab) Create() error {
46-
4744
nsID, err := g.getNamespaceID()
4845
if err != nil {
4946
return err
@@ -136,7 +133,6 @@ func (g *Gitlab) updateDisplayName() (bool, error) {
136133

137134
func (g *Gitlab) removeDeployKeys(deleteKeys map[string]synv1alpha1.DeployKey) error {
138135
existingKeys, _, err := g.client.DeployKeys.ListProjectDeployKeys(g.project.ID, &gitlab.ListProjectDeployKeysOptions{})
139-
140136
if err != nil {
141137
return err
142138
}
@@ -227,11 +223,21 @@ func (g *Gitlab) Connect() error {
227223

228224
// FullURL returns the complete url of this git repository
229225
func (g *Gitlab) FullURL() *url.URL {
230-
231226
sshURL := *g.ops.URL
232227

233228
sshURL.Scheme = "ssh"
234229
sshURL.User = url.User("git")
230+
if g.ops.SSHHost != "" {
231+
sshURL.Host = g.ops.SSHHost
232+
// If the original URL had no scheme (for example "git.example.com/foo/bar"),
233+
// the host ends up in Path. Strip it when overriding the host.
234+
if g.ops.URL.Host == "" {
235+
trimmed := strings.TrimPrefix(sshURL.Path, "/")
236+
if parts := strings.SplitN(trimmed, "/", 2); len(parts) == 2 {
237+
sshURL.Path = "/" + parts[1]
238+
}
239+
}
240+
}
235241
sshURL.Path = sshURL.Path + ".git"
236242

237243
return &sshURL
@@ -260,7 +266,6 @@ func (g *Gitlab) New(options manager.RepoOptions) (manager.Repo, error) {
260266
}
261267

262268
func (g *Gitlab) getNamespaceID() (*int, error) {
263-
264269
fetchedNamespace, _, err := g.client.Namespaces.GetNamespace(g.ops.Path)
265270
if err != nil {
266271
return nil, err
@@ -338,7 +343,6 @@ func (g *Gitlab) getDeployKeys() (map[string]synv1alpha1.DeployKey, error) {
338343

339344
// CommitTemplateFiles uploads all defined template files onto the repository.
340345
func (g *Gitlab) CommitTemplateFiles() error {
341-
342346
if len(g.ops.TemplateFiles) == 0 {
343347
return nil
344348
}
@@ -386,7 +390,6 @@ func (g *Gitlab) CommitTemplateFiles() error {
386390
// files that should be created. If there are existing files they will be
387391
// dropped.
388392
func (g *Gitlab) compareFiles() ([]manager.CommitFile, error) {
389-
390393
files := make([]manager.CommitFile, 0)
391394
resp := &gitlab.Response{NextPage: 1}
392395
var trees []*gitlab.TreeNode
@@ -438,14 +441,12 @@ func (g *Gitlab) compareFiles() ([]manager.CommitFile, error) {
438441
Content: content,
439442
})
440443
}
441-
442444
}
443445

444446
return files, nil
445447
}
446448

447449
func (g *Gitlab) getCommitOptions() *gitlab.CreateCommitOptions {
448-
449450
co := &gitlab.CreateCommitOptions{
450451
AuthorEmail: ptr.To("lieutenant-operator@syn.local"),
451452
AuthorName: ptr.To("Lieutenant Operator"),
@@ -530,7 +531,6 @@ func (g *Gitlab) EnsureProjectAccessToken(ctx context.Context, name string, opts
530531
Scopes: ptr.To([]string{"write_repository"}),
531532
AccessLevel: ptr.To(gitlab.MaintainerPermissions),
532533
}, gitlab.WithContext(ctx))
533-
534534
if err != nil {
535535
return manager.ProjectAccessToken{}, fmt.Errorf("error response from gitlab when creating ProjectAccessToken: %w", err)
536536
}

git/gitlab/gitlab_test.go

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,6 @@ func TestGitlab_Read(t *testing.T) {
6969
}
7070
for _, tt := range tests {
7171
t.Run(tt.name, func(t *testing.T) {
72-
7372
defer tt.httpServer.Close()
7473

7574
serverURL, _ := url.Parse(tt.httpServer.URL)
@@ -176,7 +175,6 @@ func TestGitlab_Create(t *testing.T) {
176175
}
177176
for _, tt := range tests {
178177
t.Run(tt.name, func(t *testing.T) {
179-
180178
defer tt.httpServer.Close()
181179

182180
serverURL, _ := url.Parse(tt.httpServer.URL)
@@ -234,7 +232,6 @@ func TestGitlab_Delete(t *testing.T) {
234232
}
235233
for _, tt := range tests {
236234
t.Run(tt.name, func(t *testing.T) {
237-
238235
defer tt.httpServer.Close()
239236

240237
serverURL, _ := url.Parse(tt.httpServer.URL)
@@ -270,7 +267,6 @@ func testGetUpdateServer(t *testing.T, fail bool) *httptest.Server {
270267
mux := http.NewServeMux()
271268

272269
mux.HandleFunc("/api/v4/projects/3/deploy_keys", func(res http.ResponseWriter, req *http.Request) {
273-
274270
respH := http.StatusOK
275271
if fail {
276272
respH = http.StatusInternalServerError
@@ -329,7 +325,6 @@ func testGetUpdateServer(t *testing.T, fail bool) *httptest.Server {
329325
mux.HandleFunc("/", testutils.LogNotFoundHandler(t))
330326

331327
return httptest.NewServer(mux)
332-
333328
}
334329

335330
func TestGitlab_Update(t *testing.T) {
@@ -377,7 +372,6 @@ func TestGitlab_Update(t *testing.T) {
377372

378373
for _, tt := range tests {
379374
t.Run(tt.name, func(t *testing.T) {
380-
381375
defer tt.httpServer.Close()
382376

383377
serverURL, _ := url.Parse(tt.httpServer.URL)
@@ -601,7 +595,6 @@ func TestGitlab_CommitTemplateFiles(t *testing.T) {
601595
ListItemsPerPage = 1 // simulate pagination
602596
for name, tt := range tests {
603597
t.Run(name, func(t *testing.T) {
604-
605598
tt.fields.ops.URL, _ = url.Parse(tt.httpServer.URL)
606599

607600
g := &Gitlab{
@@ -635,6 +628,15 @@ func TestGitlab_FullURL(t *testing.T) {
635628
assert.Equal(t, expectedFullURL, g.FullURL().String())
636629
assert.Equal(t, expectedFullURL, g.FullURL().String())
637630
assert.Equal(t, expectedFullURL, g.FullURL().String())
631+
632+
sshHostExpected := "ssh://git@ssh.example.com/foo/bar.git"
633+
g = &Gitlab{
634+
ops: manager.RepoOptions{
635+
URL: serverURL,
636+
SSHHost: "ssh.example.com",
637+
},
638+
}
639+
assert.Equal(t, sshHostExpected, g.FullURL().String())
638640
}
639641

640642
type mockClock struct {

git/manager/manager.go

Lines changed: 51 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"errors"
66
"fmt"
77
"net/url"
8+
"strings"
89
"time"
910

1011
synv1alpha1 "github.com/projectsyn/lieutenant-operator/api/v1alpha1"
@@ -23,15 +24,15 @@ const (
2324
SecretHostKeysName = "hostKeys"
2425
// SecretEndpointName is the name of the secret entry containing the api endpoint
2526
SecretEndpointName = "endpoint"
27+
// SecretSSHEndpointName is the name of the secret entry containing the ssh endpoint (optional)
28+
SecretSSHEndpointName = "sshEndpoint"
2629
// DeletionMagicString defines when a file should be deleted from the repository
27-
//TODO it will be replaced with something better in the future TODO
30+
// TODO it will be replaced with something better in the future TODO
2831
DeletionMagicString = "{delete}"
2932
)
3033

31-
var (
32-
// implementations holds each a copy of the registered Git implementation
33-
implementations []Implementation
34-
)
34+
// implementations holds each a copy of the registered Git implementation
35+
var implementations []Implementation
3536

3637
// Register adds a type to the list of supported Git implementations.
3738
func Register(i Implementation) {
@@ -40,7 +41,6 @@ func Register(i Implementation) {
4041

4142
// NewRepo returns a Repo object that can handle the specific URL
4243
func NewRepo(opts RepoOptions) (Repo, error) {
43-
4444
for _, imp := range implementations {
4545
if exists, err := imp.IsType(opts.URL); exists {
4646
newImp, err := imp.New(opts)
@@ -66,6 +66,7 @@ type RepoOptions struct {
6666
DeployKeys map[string]synv1alpha1.DeployKey
6767
Logger logr.Logger
6868
URL *url.URL
69+
SSHHost string
6970
Path string
7071
RepoName string
7172
DisplayName string
@@ -212,11 +213,19 @@ func GetGitClient(ctx context.Context, instance *synv1alpha1.GitRepoTemplate, na
212213
}
213214

214215
repoURL, err := url.Parse(string(secret.Data[SecretEndpointName]) + "/" + instance.Path + "/" + instance.RepoName)
215-
216216
if err != nil {
217217
return nil, "", err
218218
}
219219

220+
sshHost := ""
221+
if raw, ok := secret.Data[SecretSSHEndpointName]; ok {
222+
parsed, err := parseSSHEndpoint(string(raw))
223+
if err != nil {
224+
return nil, "", fmt.Errorf("invalid ssh endpoint in secret %s: %w", secret.GetName(), err)
225+
}
226+
sshHost = parsed
227+
}
228+
220229
repoOptions := RepoOptions{
221230
Credentials: Credentials{
222231
Token: string(secret.Data[SecretTokenName]),
@@ -227,6 +236,7 @@ func GetGitClient(ctx context.Context, instance *synv1alpha1.GitRepoTemplate, na
227236
RepoName: instance.RepoName,
228237
DisplayName: instance.DisplayName,
229238
URL: repoURL,
239+
SSHHost: sshHost,
230240
TemplateFiles: instance.TemplateFiles,
231241
DeletionPolicy: instance.DeletionPolicy,
232242
}
@@ -239,5 +249,39 @@ func GetGitClient(ctx context.Context, instance *synv1alpha1.GitRepoTemplate, na
239249
err = repo.Connect()
240250

241251
return repo, hostKeysString, err
252+
}
253+
254+
func parseSSHEndpoint(raw string) (string, error) {
255+
trimmed := strings.TrimSpace(raw)
256+
if trimmed == "" {
257+
return "", nil
258+
}
259+
260+
parsed, err := url.Parse(trimmed)
261+
if err != nil {
262+
return "", err
263+
}
264+
265+
if parsed.Host != "" {
266+
return parsed.Host, nil
267+
}
268+
269+
host := strings.TrimSpace(parsed.Path)
270+
if host == "" {
271+
return "", fmt.Errorf("ssh endpoint has no host")
272+
}
273+
274+
if at := strings.LastIndex(host, "@"); at >= 0 {
275+
host = host[at+1:]
276+
}
277+
278+
if slash := strings.Index(host, "/"); slash >= 0 {
279+
host = host[:slash]
280+
}
281+
282+
if host == "" {
283+
return "", fmt.Errorf("ssh endpoint has no host")
284+
}
242285

286+
return host, nil
243287
}

git/manager/manager_test.go

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,6 @@ func (t *testImplementation) New(options RepoOptions) (Repo, error) {
2828

2929
//goland:noinspection HttpUrlsUsage
3030
func TestNewRepo(t *testing.T) {
31-
3231
Register(&testImplementation{found: false})
3332
Register(&testImplementation{found: true})
3433
Register(&testImplementation{found: false})
@@ -86,3 +85,55 @@ func TestNewRepo(t *testing.T) {
8685
})
8786
}
8887
}
88+
89+
func TestParseSSHEndpoint(t *testing.T) {
90+
tests := []struct {
91+
name string
92+
raw string
93+
want string
94+
wantErr bool
95+
}{
96+
{
97+
name: "empty endpoint",
98+
raw: " \n\t ",
99+
want: "",
100+
},
101+
{
102+
name: "ssh url with port",
103+
raw: "ssh://git@example.com:2222",
104+
want: "example.com:2222",
105+
},
106+
{
107+
name: "host without scheme",
108+
raw: "git.example.com",
109+
want: "git.example.com",
110+
},
111+
{
112+
name: "schemeless endpoint with path is normalized",
113+
raw: "git.example.com/foo/bar",
114+
want: "git.example.com",
115+
},
116+
{
117+
name: "scp style endpoint",
118+
raw: "git@example.com",
119+
want: "example.com",
120+
},
121+
{
122+
name: "invalid endpoint",
123+
raw: "ssh://[::1",
124+
wantErr: true,
125+
},
126+
}
127+
128+
for _, tt := range tests {
129+
t.Run(tt.name, func(t *testing.T) {
130+
got, err := parseSSHEndpoint(tt.raw)
131+
if (err != nil) != tt.wantErr {
132+
t.Fatalf("parseSSHEndpoint() error = %v, wantErr %v", err, tt.wantErr)
133+
}
134+
if got != tt.want {
135+
t.Errorf("parseSSHEndpoint() = %q, want %q", got, tt.want)
136+
}
137+
})
138+
}
139+
}

0 commit comments

Comments
 (0)