diff --git a/docs/modules/ROOT/pages/how-tos/gitlab-connection.adoc b/docs/modules/ROOT/pages/how-tos/gitlab-connection.adoc index ee1b1d78..bfab51f7 100644 --- a/docs/modules/ROOT/pages/how-tos/gitlab-connection.adoc +++ b/docs/modules/ROOT/pages/how-tos/gitlab-connection.adoc @@ -14,9 +14,16 @@ image::gitlab_settings.png[] Before any other things can be created we need to specify the Git repository API endpoint first: +[NOTE] +==== +`sshEndpoint` is optional. If omitted, the operator uses the same host as `endpoint` for SSH. +Provide a full SSH URL (for example `ssh://gitlab-ssh.example.com`) or just a host name. +==== + [source,shell] .... kubectl -n lieutenant create secret generic lieutenant-secret \ --from-literal endpoint=http://10.144.1.197:8080 \ + --from-literal sshEndpoint=ssh://gitlab-ssh.example.com \ --from-literal token= .... diff --git a/git/gitlab/gitlab.go b/git/gitlab/gitlab.go index 858efc25..b51a9c9d 100644 --- a/git/gitlab/gitlab.go +++ b/git/gitlab/gitlab.go @@ -26,9 +26,7 @@ func init() { manager.Register(&Gitlab{}) } -var ( - ListItemsPerPage = 100 -) +var ListItemsPerPage = 100 // Gitlab holds the necessary information to communincate with a Gitlab server. // Each Gitlab instance will handle exactly one project. @@ -43,7 +41,6 @@ type Gitlab struct { // Create will create a new Gitlab project func (g *Gitlab) Create() error { - nsID, err := g.getNamespaceID() if err != nil { return err @@ -136,7 +133,6 @@ func (g *Gitlab) updateDisplayName() (bool, error) { func (g *Gitlab) removeDeployKeys(deleteKeys map[string]synv1alpha1.DeployKey) error { existingKeys, _, err := g.client.DeployKeys.ListProjectDeployKeys(g.project.ID, &gitlab.ListProjectDeployKeysOptions{}) - if err != nil { return err } @@ -227,11 +223,21 @@ func (g *Gitlab) Connect() error { // FullURL returns the complete url of this git repository func (g *Gitlab) FullURL() *url.URL { - sshURL := *g.ops.URL sshURL.Scheme = "ssh" sshURL.User = url.User("git") + if g.ops.SSHHost != "" { + sshURL.Host = g.ops.SSHHost + // If the original URL had no host (for example "git.example.com/foo/bar"), + // the host ends up in Path. Strip it when overriding the host. + if g.ops.URL.Host == "" { + trimmed := strings.TrimPrefix(sshURL.Path, "/") + if parts := strings.SplitN(trimmed, "/", 2); len(parts) == 2 { + sshURL.Path = "/" + parts[1] + } + } + } sshURL.Path = sshURL.Path + ".git" return &sshURL @@ -260,7 +266,6 @@ func (g *Gitlab) New(options manager.RepoOptions) (manager.Repo, error) { } func (g *Gitlab) getNamespaceID() (*int, error) { - fetchedNamespace, _, err := g.client.Namespaces.GetNamespace(g.ops.Path) if err != nil { return nil, err @@ -338,7 +343,6 @@ func (g *Gitlab) getDeployKeys() (map[string]synv1alpha1.DeployKey, error) { // CommitTemplateFiles uploads all defined template files onto the repository. func (g *Gitlab) CommitTemplateFiles() error { - if len(g.ops.TemplateFiles) == 0 { return nil } @@ -386,7 +390,6 @@ func (g *Gitlab) CommitTemplateFiles() error { // files that should be created. If there are existing files they will be // dropped. func (g *Gitlab) compareFiles() ([]manager.CommitFile, error) { - files := make([]manager.CommitFile, 0) resp := &gitlab.Response{NextPage: 1} var trees []*gitlab.TreeNode @@ -438,14 +441,12 @@ func (g *Gitlab) compareFiles() ([]manager.CommitFile, error) { Content: content, }) } - } return files, nil } func (g *Gitlab) getCommitOptions() *gitlab.CreateCommitOptions { - co := &gitlab.CreateCommitOptions{ AuthorEmail: ptr.To("lieutenant-operator@syn.local"), AuthorName: ptr.To("Lieutenant Operator"), @@ -530,7 +531,6 @@ func (g *Gitlab) EnsureProjectAccessToken(ctx context.Context, name string, opts Scopes: ptr.To([]string{"write_repository"}), AccessLevel: ptr.To(gitlab.MaintainerPermissions), }, gitlab.WithContext(ctx)) - if err != nil { return manager.ProjectAccessToken{}, fmt.Errorf("error response from gitlab when creating ProjectAccessToken: %w", err) } diff --git a/git/gitlab/gitlab_test.go b/git/gitlab/gitlab_test.go index 1c40eceb..83579113 100644 --- a/git/gitlab/gitlab_test.go +++ b/git/gitlab/gitlab_test.go @@ -69,7 +69,6 @@ func TestGitlab_Read(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - defer tt.httpServer.Close() serverURL, _ := url.Parse(tt.httpServer.URL) @@ -176,7 +175,6 @@ func TestGitlab_Create(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - defer tt.httpServer.Close() serverURL, _ := url.Parse(tt.httpServer.URL) @@ -234,7 +232,6 @@ func TestGitlab_Delete(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - defer tt.httpServer.Close() serverURL, _ := url.Parse(tt.httpServer.URL) @@ -270,7 +267,6 @@ func testGetUpdateServer(t *testing.T, fail bool) *httptest.Server { mux := http.NewServeMux() mux.HandleFunc("/api/v4/projects/3/deploy_keys", func(res http.ResponseWriter, req *http.Request) { - respH := http.StatusOK if fail { respH = http.StatusInternalServerError @@ -329,7 +325,6 @@ func testGetUpdateServer(t *testing.T, fail bool) *httptest.Server { mux.HandleFunc("/", testutils.LogNotFoundHandler(t)) return httptest.NewServer(mux) - } func TestGitlab_Update(t *testing.T) { @@ -377,7 +372,6 @@ func TestGitlab_Update(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - defer tt.httpServer.Close() serverURL, _ := url.Parse(tt.httpServer.URL) @@ -601,7 +595,6 @@ func TestGitlab_CommitTemplateFiles(t *testing.T) { ListItemsPerPage = 1 // simulate pagination for name, tt := range tests { t.Run(name, func(t *testing.T) { - tt.fields.ops.URL, _ = url.Parse(tt.httpServer.URL) g := &Gitlab{ @@ -635,6 +628,15 @@ func TestGitlab_FullURL(t *testing.T) { assert.Equal(t, expectedFullURL, g.FullURL().String()) assert.Equal(t, expectedFullURL, g.FullURL().String()) assert.Equal(t, expectedFullURL, g.FullURL().String()) + + sshHostExpected := "ssh://git@ssh.example.com/foo/bar.git" + g = &Gitlab{ + ops: manager.RepoOptions{ + URL: serverURL, + SSHHost: "ssh.example.com", + }, + } + assert.Equal(t, sshHostExpected, g.FullURL().String()) } type mockClock struct { diff --git a/git/manager/manager.go b/git/manager/manager.go index c11bc76d..ff4ee335 100644 --- a/git/manager/manager.go +++ b/git/manager/manager.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "net/url" + "strings" "time" synv1alpha1 "github.com/projectsyn/lieutenant-operator/api/v1alpha1" @@ -23,15 +24,15 @@ const ( SecretHostKeysName = "hostKeys" // SecretEndpointName is the name of the secret entry containing the api endpoint SecretEndpointName = "endpoint" + // SecretSSHEndpointName is the name of the secret entry containing the ssh endpoint (optional) + SecretSSHEndpointName = "sshEndpoint" // DeletionMagicString defines when a file should be deleted from the repository - //TODO it will be replaced with something better in the future TODO + // TODO it will be replaced with something better in the future TODO DeletionMagicString = "{delete}" ) -var ( - // implementations holds each a copy of the registered Git implementation - implementations []Implementation -) +// implementations holds each a copy of the registered Git implementation +var implementations []Implementation // Register adds a type to the list of supported Git implementations. func Register(i Implementation) { @@ -40,7 +41,6 @@ func Register(i Implementation) { // NewRepo returns a Repo object that can handle the specific URL func NewRepo(opts RepoOptions) (Repo, error) { - for _, imp := range implementations { if exists, err := imp.IsType(opts.URL); exists { newImp, err := imp.New(opts) @@ -66,6 +66,7 @@ type RepoOptions struct { DeployKeys map[string]synv1alpha1.DeployKey Logger logr.Logger URL *url.URL + SSHHost string Path string RepoName string DisplayName string @@ -212,11 +213,19 @@ func GetGitClient(ctx context.Context, instance *synv1alpha1.GitRepoTemplate, na } repoURL, err := url.Parse(string(secret.Data[SecretEndpointName]) + "/" + instance.Path + "/" + instance.RepoName) - if err != nil { return nil, "", err } + sshHost := "" + if raw, ok := secret.Data[SecretSSHEndpointName]; ok { + parsed, err := parseSSHEndpoint(string(raw)) + if err != nil { + return nil, "", fmt.Errorf("invalid ssh endpoint in secret %s: %w", secret.GetName(), err) + } + sshHost = parsed + } + repoOptions := RepoOptions{ Credentials: Credentials{ Token: string(secret.Data[SecretTokenName]), @@ -227,6 +236,7 @@ func GetGitClient(ctx context.Context, instance *synv1alpha1.GitRepoTemplate, na RepoName: instance.RepoName, DisplayName: instance.DisplayName, URL: repoURL, + SSHHost: sshHost, TemplateFiles: instance.TemplateFiles, DeletionPolicy: instance.DeletionPolicy, } @@ -239,5 +249,31 @@ func GetGitClient(ctx context.Context, instance *synv1alpha1.GitRepoTemplate, na err = repo.Connect() return repo, hostKeysString, err +} + +func parseSSHEndpoint(raw string) (string, error) { + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + return "", nil + } + + parsed, err := url.Parse(trimmed) + if err != nil { + return "", err + } + + if parsed.Host != "" { + return parsed.Host, nil + } + + host := strings.TrimSpace(parsed.Path) + if host == "" { + return "", fmt.Errorf("ssh endpoint has no host") + } + + if at := strings.LastIndex(host, "@"); at >= 0 { + host = host[at+1:] + } + return host, nil }