From 03750ab02f7650e12dd5df0fd01ffdf8aa78f89f Mon Sep 17 00:00:00 2001 From: danielm-codefresh <94960579+danielm-codefresh@users.noreply.github.com> Date: Sun, 28 Nov 2021 10:18:00 +0200 Subject: [PATCH] Support gitlab as a SCM provider (#209) * Added gitlab support * Generated mocks for gitlab client * Added tests for gitlab provider * Added gitlab to be a part of the supported providers * Fixed typo * ran codegen * fixed failed test --- docs/Getting-Started.md | 2 +- .../argocd-autopilot_repo_bootstrap.md | 2 +- go.mod | 1 + go.sum | 2 + pkg/git/gitlab/mocks/gitlab_client.go | 129 ++++++++++++ pkg/git/provider.go | 1 + pkg/git/provider_gitlab.go | 102 ++++++++++ pkg/git/provider_gitlab_test.go | 191 ++++++++++++++++++ pkg/git/repository_test.go | 2 +- 9 files changed, 429 insertions(+), 3 deletions(-) create mode 100644 pkg/git/gitlab/mocks/gitlab_client.go create mode 100644 pkg/git/provider_gitlab.go create mode 100644 pkg/git/provider_gitlab_test.go diff --git a/docs/Getting-Started.md b/docs/Getting-Started.md index aed0559b..08d5a1d7 100644 --- a/docs/Getting-Started.md +++ b/docs/Getting-Started.md @@ -36,7 +36,7 @@ export GIT_REPO=https://github.com/owner/name?ref=gitops_branch #### Using a Specific git Provider You can add the `--provider` flag to the `repo bootstrap` command, to enforce using a specific provider when creating a new repository. If the value is not supplied, the code will attempt to infer it from the clone URL. -Autopilot currently support github and gitea as SCM providers. +Autopilot currently support github, gitlab and gitea as SCM providers. All the following commands will use the variables you supplied in order to manage your GitOps repository. diff --git a/docs/commands/argocd-autopilot_repo_bootstrap.md b/docs/commands/argocd-autopilot_repo_bootstrap.md index 9bd446b8..18612d3a 100644 --- a/docs/commands/argocd-autopilot_repo_bootstrap.md +++ b/docs/commands/argocd-autopilot_repo_bootstrap.md @@ -45,7 +45,7 @@ argocd-autopilot repo bootstrap [flags] --installation-mode string One of: normal|flat. If flat, will commit the bootstrap manifests, otherwise will commit the bootstrap kustomization.yaml (default "normal") --kubeconfig string Path to the kubeconfig file to use for CLI requests. -n, --namespace string If present, the namespace scope for this CLI request - --provider string The git provider, one of: gitea|github + --provider string The git provider, one of: gitea|github|gitlab --repo string Repository URL [GIT_REPO] --request-timeout string The length of time to wait before giving up on a single server request. Non-zero values should contain a corresponding time unit (e.g. 1s, 2m, 3h). A value of zero means don't timeout requests. (default "0") ``` diff --git a/go.mod b/go.mod index 84f0736f..5539feba 100644 --- a/go.mod +++ b/go.mod @@ -18,6 +18,7 @@ require ( github.com/spf13/pflag v1.0.5 github.com/spf13/viper v1.7.1 github.com/stretchr/testify v1.7.0 + github.com/xanzy/go-gitlab v0.52.2 k8s.io/api v0.21.3 k8s.io/apimachinery v0.21.1 k8s.io/cli-runtime v0.21.1 diff --git a/go.sum b/go.sum index 3c47cf80..2a0e4bfe 100644 --- a/go.sum +++ b/go.sum @@ -855,6 +855,8 @@ github.com/vmihailenco/tagparser v0.1.2/go.mod h1:OeAg3pn3UbLjkWt+rN9oFYB6u/cQgq github.com/vmware/govmomi v0.20.3/go.mod h1:URlwyTFZX72RmxtxuaFL2Uj3fD1JTvZdx59bHWk6aFU= github.com/willf/bitset v1.1.11/go.mod h1:83CECat5yLh5zVOf4P1ErAgKA5UDvKtgyUABdr3+MjI= github.com/xanzy/go-gitlab v0.50.0/go.mod h1:Q+hQhV508bDPoBijv7YjK/Lvlb4PhVhJdKqXVQrUoAE= +github.com/xanzy/go-gitlab v0.52.2 h1:gkgg1z4ON70sphibtD86Bfmt1qV3mZ0pU0CBBCFAEvQ= +github.com/xanzy/go-gitlab v0.52.2/go.mod h1:Q+hQhV508bDPoBijv7YjK/Lvlb4PhVhJdKqXVQrUoAE= github.com/xanzy/ssh-agent v0.2.1/go.mod h1:mLlQY/MoOhWBj+gOGMQkOeiEvkx+8pJSI+0Bx9h2kr4= github.com/xanzy/ssh-agent v0.3.0 h1:wUMzuKtKilRgBAD1sUb8gOwwRr2FGoBVumcjoOACClI= github.com/xanzy/ssh-agent v0.3.0/go.mod h1:3s9xbODqPuuhK9JV1R321M/FlMZSBvE5aY6eAcqrDh0= diff --git a/pkg/git/gitlab/mocks/gitlab_client.go b/pkg/git/gitlab/mocks/gitlab_client.go new file mode 100644 index 00000000..c8336c7d --- /dev/null +++ b/pkg/git/gitlab/mocks/gitlab_client.go @@ -0,0 +1,129 @@ +// Code generated by mockery (devel). DO NOT EDIT. + +package mocks + +import ( + mock "github.com/stretchr/testify/mock" + gitlab "github.com/xanzy/go-gitlab" +) + +// GitlabClient is an autogenerated mock type for the GitlabClient type +type GitlabClient struct { + mock.Mock +} + +// CreateProject provides a mock function with given fields: opt, options +func (_m *GitlabClient) CreateProject(opt *gitlab.CreateProjectOptions, options ...gitlab.RequestOptionFunc) (*gitlab.Project, *gitlab.Response, error) { + _va := make([]interface{}, len(options)) + for _i := range options { + _va[_i] = options[_i] + } + var _ca []interface{} + _ca = append(_ca, opt) + _ca = append(_ca, _va...) + ret := _m.Called(_ca...) + + var r0 *gitlab.Project + if rf, ok := ret.Get(0).(func(*gitlab.CreateProjectOptions, ...gitlab.RequestOptionFunc) *gitlab.Project); ok { + r0 = rf(opt, options...) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*gitlab.Project) + } + } + + var r1 *gitlab.Response + if rf, ok := ret.Get(1).(func(*gitlab.CreateProjectOptions, ...gitlab.RequestOptionFunc) *gitlab.Response); ok { + r1 = rf(opt, options...) + } else { + if ret.Get(1) != nil { + r1 = ret.Get(1).(*gitlab.Response) + } + } + + var r2 error + if rf, ok := ret.Get(2).(func(*gitlab.CreateProjectOptions, ...gitlab.RequestOptionFunc) error); ok { + r2 = rf(opt, options...) + } else { + r2 = ret.Error(2) + } + + return r0, r1, r2 +} + +// CurrentUser provides a mock function with given fields: options +func (_m *GitlabClient) CurrentUser(options ...gitlab.RequestOptionFunc) (*gitlab.User, *gitlab.Response, error) { + _va := make([]interface{}, len(options)) + for _i := range options { + _va[_i] = options[_i] + } + var _ca []interface{} + _ca = append(_ca, _va...) + ret := _m.Called(_ca...) + + var r0 *gitlab.User + if rf, ok := ret.Get(0).(func(...gitlab.RequestOptionFunc) *gitlab.User); ok { + r0 = rf(options...) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*gitlab.User) + } + } + + var r1 *gitlab.Response + if rf, ok := ret.Get(1).(func(...gitlab.RequestOptionFunc) *gitlab.Response); ok { + r1 = rf(options...) + } else { + if ret.Get(1) != nil { + r1 = ret.Get(1).(*gitlab.Response) + } + } + + var r2 error + if rf, ok := ret.Get(2).(func(...gitlab.RequestOptionFunc) error); ok { + r2 = rf(options...) + } else { + r2 = ret.Error(2) + } + + return r0, r1, r2 +} + +// ListGroups provides a mock function with given fields: opt, options +func (_m *GitlabClient) ListGroups(opt *gitlab.ListGroupsOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.Group, *gitlab.Response, error) { + _va := make([]interface{}, len(options)) + for _i := range options { + _va[_i] = options[_i] + } + var _ca []interface{} + _ca = append(_ca, opt) + _ca = append(_ca, _va...) + ret := _m.Called(_ca...) + + var r0 []*gitlab.Group + if rf, ok := ret.Get(0).(func(*gitlab.ListGroupsOptions, ...gitlab.RequestOptionFunc) []*gitlab.Group); ok { + r0 = rf(opt, options...) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*gitlab.Group) + } + } + + var r1 *gitlab.Response + if rf, ok := ret.Get(1).(func(*gitlab.ListGroupsOptions, ...gitlab.RequestOptionFunc) *gitlab.Response); ok { + r1 = rf(opt, options...) + } else { + if ret.Get(1) != nil { + r1 = ret.Get(1).(*gitlab.Response) + } + } + + var r2 error + if rf, ok := ret.Get(2).(func(*gitlab.ListGroupsOptions, ...gitlab.RequestOptionFunc) error); ok { + r2 = rf(opt, options...) + } else { + r2 = ret.Error(2) + } + + return r0, r1, r2 +} diff --git a/pkg/git/provider.go b/pkg/git/provider.go index 4fa6843c..8d93256d 100644 --- a/pkg/git/provider.go +++ b/pkg/git/provider.go @@ -53,6 +53,7 @@ var ( var supportedProviders = map[string]func(*ProviderOptions) (Provider, error){ "github": newGithub, "gitea": newGitea, + "gitlab": newGitlab, } // New creates a new git provider diff --git a/pkg/git/provider_gitlab.go b/pkg/git/provider_gitlab.go new file mode 100644 index 00000000..a9d91707 --- /dev/null +++ b/pkg/git/provider_gitlab.go @@ -0,0 +1,102 @@ +package git + +import ( + "context" + "fmt" + + gl "github.com/xanzy/go-gitlab" +) + +//go:generate mockery --name GitlabClient --output gitlab/mocks --case snake + +type ( + GitlabClient interface { + CurrentUser(options ...gl.RequestOptionFunc) (*gl.User, *gl.Response, error) + CreateProject(opt *gl.CreateProjectOptions, options ...gl.RequestOptionFunc) (*gl.Project, *gl.Response, error) + ListGroups(opt *gl.ListGroupsOptions, options ...gl.RequestOptionFunc) ([]*gl.Group, *gl.Response, error) + } + + clientImpl struct { + gl.ProjectsService + gl.UsersService + gl.GroupsService + } + + gitlab struct { + opts *ProviderOptions + client GitlabClient + } +) + +func newGitlab(opts *ProviderOptions) (Provider, error) { + c, err := gl.NewClient(opts.Auth.Password) + if err != nil { + return nil, err + } + + g := &gitlab{ + opts: opts, + client: &clientImpl{ + ProjectsService: *c.Projects, + UsersService: *c.Users, + GroupsService: *c.Groups, + }, + } + + return g, nil +} + +func (g *gitlab) CreateRepository(ctx context.Context, opts *CreateRepoOptions) (string, error) { + authUser, res, err := g.client.CurrentUser() + if err != nil { + if res.StatusCode == 401 { + return "", ErrAuthenticationFailed(err) + } + + return "", err + } + + createOpts := gl.CreateProjectOptions{ + Name: &opts.Name, + Visibility: gl.Visibility(gl.PublicVisibility), + } + + if opts.Private { + createOpts.Visibility = gl.Visibility(gl.PrivateVisibility) + } + + if authUser.Username != opts.Owner { + groupId, err := g.getGroupIdByName(opts.Owner) + if err != nil { + return "", err + } + createOpts.NamespaceID = gl.Int(groupId) + } + + p, _, err := g.client.CreateProject(&createOpts) + if err != nil { + return "", fmt.Errorf("failed creating the project %s under %s: %w", opts.Name, opts.Owner, err) + } + + if p.WebURL == "" { + return "", fmt.Errorf("project url is empty") + } + + return p.WebURL, err +} + +func (g *gitlab) getGroupIdByName(groupName string) (int, error) { + groups, _, err := g.client.ListGroups(&gl.ListGroupsOptions{ + MinAccessLevel: gl.AccessLevel(gl.DeveloperPermissions), + }) + if err != nil { + return 0, err + } + + for _, group := range groups { + if group.Path == groupName { + return group.ID, nil + } + } + return 0, fmt.Errorf("group %s not found", groupName) +} diff --git a/pkg/git/provider_gitlab_test.go b/pkg/git/provider_gitlab_test.go new file mode 100644 index 00000000..625c52d9 --- /dev/null +++ b/pkg/git/provider_gitlab_test.go @@ -0,0 +1,191 @@ +package git + +import ( + "context" + "errors" + "net/http" + "testing" + + glmocks "github.com/argoproj-labs/argocd-autopilot/pkg/git/gitlab/mocks" + gl "github.com/xanzy/go-gitlab" + + "github.com/stretchr/testify/assert" +) + +func Test_gitlab_CreateRepository(t *testing.T) { + tests := map[string]struct { + opts *CreateRepoOptions + beforeFn func(*glmocks.GitlabClient) + want string + wantErr string + }{ + "Fails if credentials are wrong": { + opts: &CreateRepoOptions{ + Name: "projectName", + Owner: "username", + }, + beforeFn: func(c *glmocks.GitlabClient) { + res := &gl.Response{ + Response: &http.Response{ + StatusCode: 401, + }, + } + c.On("CurrentUser").Return(nil, res, errors.New("some error")) + }, + wantErr: "authentication failed, make sure credentials are correct: some error", + }, + "Fails if can't find current user": { + opts: &CreateRepoOptions{ + Name: "projectName", + Owner: "username", + }, + beforeFn: func(c *glmocks.GitlabClient) { + res := &gl.Response{ + Response: &http.Response{}, + } + c.On("CurrentUser").Return(nil, res, errors.New("some error")) + }, + wantErr: "some error", + }, + "Fails if can't find group": { + opts: &CreateRepoOptions{ + Name: "projectName", + Owner: "org", + }, + beforeFn: func(c *glmocks.GitlabClient) { + u := &gl.User{Username: "username"} + c.On("CurrentUser").Return(u, nil, nil) + g := []*gl.Group{&gl.Group{Path: "anotherOrg", ID: 1}} + c.On("ListGroups", &gl.ListGroupsOptions{ + MinAccessLevel: gl.AccessLevel(gl.DeveloperPermissions), + }).Return(g, nil, nil) + }, + wantErr: "group org not found", + }, + "Fails if can't create project": { + opts: &CreateRepoOptions{ + Name: "projectName", + Owner: "username", + }, + beforeFn: func(c *glmocks.GitlabClient) { + u := &gl.User{Username: "username"} + c.On("CurrentUser").Return(u, nil, nil) + createOpts := gl.CreateProjectOptions{ + Name: gl.String("projectName"), + Visibility: gl.Visibility(gl.PublicVisibility), + } + res := &gl.Response{ + Response: &http.Response{}, + } + c.On("CreateProject", &createOpts).Return(nil, res, errors.New("some error")) + }, + wantErr: "failed creating the project projectName under username: some error", + }, + "Creates project under user": { + opts: &CreateRepoOptions{ + Name: "projectName", + Owner: "username", + }, + beforeFn: func(c *glmocks.GitlabClient) { + u := &gl.User{Username: "username"} + c.On("CurrentUser").Return(u, nil, nil) + p := &gl.Project{WebURL: "http://gitlab.com/username/projectName"} + createOpts := gl.CreateProjectOptions{ + Name: gl.String("projectName"), + Visibility: gl.Visibility(gl.PublicVisibility), + } + c.On("CreateProject", &createOpts).Return(p, nil, nil) + }, + want: "http://gitlab.com/username/projectName", + }, + "Creates project under group": { + opts: &CreateRepoOptions{ + Name: "projectName", + Owner: "org", + }, + beforeFn: func(c *glmocks.GitlabClient) { + u := &gl.User{Username: "username"} + c.On("CurrentUser").Return(u, nil, nil) + p := &gl.Project{WebURL: "http://gitlab.com/org/projectName"} + g := []*gl.Group{&gl.Group{Path: "org", ID: 1}} + c.On("ListGroups", &gl.ListGroupsOptions{ + MinAccessLevel: gl.AccessLevel(gl.DeveloperPermissions), + }).Return(g, nil, nil) + createOpts := gl.CreateProjectOptions{ + Name: gl.String("projectName"), + Visibility: gl.Visibility(gl.PublicVisibility), + NamespaceID: gl.Int(1), + } + c.On("CreateProject", &createOpts).Return(p, nil, nil) + }, + want: "http://gitlab.com/org/projectName", + }, + "Creates private project": { + opts: &CreateRepoOptions{ + Name: "projectName", + Owner: "username", + Private: true, + }, + beforeFn: func(c *glmocks.GitlabClient) { + u := &gl.User{Username: "username"} + c.On("CurrentUser").Return(u, nil, nil) + p := &gl.Project{WebURL: "http://gitlab.com/username/projectName"} + createOpts := gl.CreateProjectOptions{ + Name: gl.String("projectName"), + Visibility: gl.Visibility(gl.PrivateVisibility), + } + res := &gl.Response{ + Response: &http.Response{}, + } + c.On("CreateProject", &createOpts).Return(p, res, nil) + }, + want: "http://gitlab.com/username/projectName", + }, + "Fails when no WebURL": { + opts: &CreateRepoOptions{ + Name: "projectName", + Owner: "username", + }, + beforeFn: func(c *glmocks.GitlabClient) { + u := &gl.User{Username: "username"} + c.On("CurrentUser").Return(u, nil, nil) + p := &gl.Project{WebURL: ""} + createOpts := gl.CreateProjectOptions{ + Name: gl.String("projectName"), + Visibility: gl.Visibility(gl.PublicVisibility), + } + res := &gl.Response{ + Response: &http.Response{}, + } + c.On("CreateProject", &createOpts).Return(p, res, nil) + }, + wantErr: "project url is empty", + want: "", + }, + } + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + mockClient := &glmocks.GitlabClient{} + tt.beforeFn(mockClient) + g := &gitlab{ + client: mockClient, + } + got, err := g.CreateRepository(context.Background(), tt.opts) + + mockClient.AssertExpectations(t) + if err != nil { + if tt.wantErr != "" { + assert.EqualError(t, err, tt.wantErr) + } else { + t.Errorf("gitlab.CreateRepository() error = %v, wantErr %v", err, tt.wantErr) + } + + return + } + + if got != tt.want { + t.Errorf("gitlab.CreateRepository() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/pkg/git/repository_test.go b/pkg/git/repository_test.go index 908edd12..e9531d5b 100644 --- a/pkg/git/repository_test.go +++ b/pkg/git/repository_test.go @@ -882,7 +882,7 @@ func TestAddFlags(t *testing.T) { wantedFlags: []flag{ { name: "provider", - usage: "The git provider, one of: gitea|github", + usage: "The git provider, one of: gitea|github|gitlab", }, }, },