diff --git a/docs/commands/argocd-autopilot_repo_bootstrap.md b/docs/commands/argocd-autopilot_repo_bootstrap.md index 42088480..5aa122f4 100644 --- a/docs/commands/argocd-autopilot_repo_bootstrap.md +++ b/docs/commands/argocd-autopilot_repo_bootstrap.md @@ -46,7 +46,7 @@ argocd-autopilot repo bootstrap [flags] --kubeconfig string Path to the kubeconfig file to use for CLI requests. -n, --namespace string If present, the namespace scope for this CLI request --namespace-labels stringToString Optional labels that will be set on the namespace resource. (e.g. "key1=value1,key2=value2" (default []) - --provider string The git provider, one of: azure|bitbucket-server|gitea|github|gitlab + --provider string The git provider, one of: azure|bitbucket|bitbucket-server|gitea|github|gitlab --recover Installs Argo-CD on a cluster without pushing installation manifests to the git repository. This is meant to be used together with --app flag to use the same Argo-CD manifests that exists in the git repository (e.g. --app https://github.com/git-user/repo-name/bootstrap/argo-cd) --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 9fd91091..bb025c44 100644 --- a/go.mod +++ b/go.mod @@ -13,6 +13,7 @@ require ( github.com/go-git/go-git/v5 v5.4.2 github.com/golang/mock v1.6.0 github.com/google/go-github/v43 v43.0.0 + github.com/ktrysmt/go-bitbucket v0.9.49 github.com/microsoft/azure-devops-go-api/azuredevops v1.0.0-b5 github.com/sirupsen/logrus v1.8.1 github.com/spf13/cobra v1.4.0 diff --git a/go.sum b/go.sum index 7960335b..edbd9095 100644 --- a/go.sum +++ b/go.sum @@ -761,6 +761,7 @@ github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8 github.com/jung-kurt/gofpdf v1.0.3-0.20190309125859-24315acbbda5/go.mod h1:7Id9E/uU8ce6rXgefFLlgrJj/GYY22cpxn+r32jIOes= github.com/k0kubun/colorstring v0.0.0-20150214042306-9440f1994b88/go.mod h1:3w7q1U84EfirKl04SVQ/s7nPm1ZPhiXd34z40TNz36k= github.com/k0kubun/pp v2.3.0+incompatible/go.mod h1:GWse8YhT0p8pT4ir3ZgBbfZild3tgzSScAn6HmfYukg= +github.com/k0kubun/pp v3.0.1+incompatible/go.mod h1:GWse8YhT0p8pT4ir3ZgBbfZild3tgzSScAn6HmfYukg= github.com/karrick/godirwalk v1.16.1/go.mod h1:j4mkqPuvaLI8mp1DroR3P6ad7cyYd4c1qeJ3RV7ULlk= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= @@ -789,6 +790,8 @@ github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/ktrysmt/go-bitbucket v0.9.40/go.mod h1:FWxy2UK7GlK5b0NSJGc5hPqnssVlkNnsChvyuOf/Xno= +github.com/ktrysmt/go-bitbucket v0.9.49 h1:VVfdYIw1SLmgD4xremGkyVY8oI84m9hSbZTK3FyBjxA= +github.com/ktrysmt/go-bitbucket v0.9.49/go.mod h1:aB/IUpoFE65X84soIfgUPT53bzp/jfYoffLN2mg3bFc= github.com/leodido/go-urn v1.1.0/go.mod h1:+cyI34gQWZcE1eQU7NVgKkkzdXDQHr1dBMtdAPozLkw= github.com/libopenstorage/openstorage v1.0.0/go.mod h1:Sp1sIObHjat1BeXhfMqLZ14wnOzEhNx2YQedreMcUyc= github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de h1:9TO3cAIGXtEhnIaL+V+BEER86oLrvS+kWobKpbJuye0= diff --git a/pkg/git/bitbucket/mocks/client.go b/pkg/git/bitbucket/mocks/client.go new file mode 100644 index 00000000..1bfdecf0 --- /dev/null +++ b/pkg/git/bitbucket/mocks/client.go @@ -0,0 +1,118 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: ./provider_bitbucket.go + +// Package mocks is a generated GoMock package. +package mocks + +import ( + reflect "reflect" + + gomock "github.com/golang/mock/gomock" + bitbucket "github.com/ktrysmt/go-bitbucket" +) + +// MockbbRepo is a mock of bbRepo interface. +type MockbbRepo struct { + ctrl *gomock.Controller + recorder *MockbbRepoMockRecorder +} + +// MockbbRepoMockRecorder is the mock recorder for MockbbRepo. +type MockbbRepoMockRecorder struct { + mock *MockbbRepo +} + +// NewMockbbRepo creates a new mock instance. +func NewMockbbRepo(ctrl *gomock.Controller) *MockbbRepo { + mock := &MockbbRepo{ctrl: ctrl} + mock.recorder = &MockbbRepoMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockbbRepo) EXPECT() *MockbbRepoMockRecorder { + return m.recorder +} + +// Create mocks base method. +func (m *MockbbRepo) Create(ro *bitbucket.RepositoryOptions) (*bitbucket.Repository, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Create", ro) + ret0, _ := ret[0].(*bitbucket.Repository) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Create indicates an expected call of Create. +func (mr *MockbbRepoMockRecorder) Create(ro interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Create", reflect.TypeOf((*MockbbRepo)(nil).Create), ro) +} + +// Get mocks base method. +func (m *MockbbRepo) Get(ro *bitbucket.RepositoryOptions) (*bitbucket.Repository, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Get", ro) + ret0, _ := ret[0].(*bitbucket.Repository) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Get indicates an expected call of Get. +func (mr *MockbbRepoMockRecorder) Get(ro interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockbbRepo)(nil).Get), ro) +} + +// MockbbUser is a mock of bbUser interface. +type MockbbUser struct { + ctrl *gomock.Controller + recorder *MockbbUserMockRecorder +} + +// MockbbUserMockRecorder is the mock recorder for MockbbUser. +type MockbbUserMockRecorder struct { + mock *MockbbUser +} + +// NewMockbbUser creates a new mock instance. +func NewMockbbUser(ctrl *gomock.Controller) *MockbbUser { + mock := &MockbbUser{ctrl: ctrl} + mock.recorder = &MockbbUserMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockbbUser) EXPECT() *MockbbUserMockRecorder { + return m.recorder +} + +// Emails mocks base method. +func (m *MockbbUser) Emails() (interface{}, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Emails") + ret0, _ := ret[0].(interface{}) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Emails indicates an expected call of Emails. +func (mr *MockbbUserMockRecorder) Emails() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Emails", reflect.TypeOf((*MockbbUser)(nil).Emails)) +} + +// Profile mocks base method. +func (m *MockbbUser) Profile() (*bitbucket.User, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Profile") + ret0, _ := ret[0].(*bitbucket.User) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Profile indicates an expected call of Profile. +func (mr *MockbbUserMockRecorder) Profile() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Profile", reflect.TypeOf((*MockbbUser)(nil).Profile)) +} diff --git a/pkg/git/provider.go b/pkg/git/provider.go index fff3865e..de3d6ffc 100644 --- a/pkg/git/provider.go +++ b/pkg/git/provider.go @@ -57,6 +57,7 @@ var ( } supportedProviders = map[string]func(*ProviderOptions) (Provider, error){ + "bitbucket": newBitbucket, BitbucketServer: newBitbucketServer, "github": newGithub, "gitea": newGitea, diff --git a/pkg/git/provider_bitbucket.go b/pkg/git/provider_bitbucket.go new file mode 100644 index 00000000..3735cc9d --- /dev/null +++ b/pkg/git/provider_bitbucket.go @@ -0,0 +1,171 @@ +package git + +import ( + "context" + "errors" + "fmt" + + bb "github.com/ktrysmt/go-bitbucket" +) + +//go:generate mockgen -destination=./bitbucket/mocks/client.go -package=mocks -source=./provider_bitbucket.go bbRepo bbUser + +type ( + bitbucket struct { + opts *ProviderOptions + Repository bbRepo + User bbUser + } + + bbRepo interface { + Create(ro *bb.RepositoryOptions) (*bb.Repository, error) + Get(ro *bb.RepositoryOptions) (*bb.Repository, error) + } + + bbUser interface { + Profile() (*bb.User, error) + Emails() (interface{}, error) + } +) + +func newBitbucket(opts *ProviderOptions) (Provider, error) { + c := bb.NewBasicAuth(opts.Auth.Username, opts.Auth.Password) + if c == nil { + return nil, errors.New("Authentication info is invalid") + } + g := &bitbucket{ + opts: opts, + Repository: c.Repositories.Repository, + User: c.User, + } + + return g, nil + +} + +func (g *bitbucket) CreateRepository(ctx context.Context, orgRepo string) (string, error) { + opts, err := getDefaultRepoOptions(orgRepo) + if err != nil { + return "", err + } + + createOpts := &bb.RepositoryOptions{ + Owner: opts.Owner, + RepoSlug: opts.Name, + Scm: "git", + } + + if opts.Private { + createOpts.IsPrivate = fmt.Sprintf("%t", opts.Private) + } + + p, err := g.Repository.Create(createOpts) + + if err != nil { + return "", fmt.Errorf("failed creating the repository \"%s\" under \"%s\": %w", opts.Name, opts.Owner, err) + } + + var cloneUrl string + cloneLinksObj := p.Links["clone"] + for _, cloneLink := range cloneLinksObj.([]interface{}) { + link := cloneLink.(map[string]interface{}) + if link["name"].(string) == "https" { + cloneUrl = link["href"].(string) + } + } + + if cloneUrl == "" { + return "", fmt.Errorf("clone url is empty") + } + + return cloneUrl, err +} + +func (g *bitbucket) GetDefaultBranch(ctx context.Context, orgRepo string) (string, error) { + opts, err := getDefaultRepoOptions(orgRepo) + if err != nil { + return "", err + } + + repoOpts := &bb.RepositoryOptions{ + Owner: opts.Owner, + RepoSlug: opts.Name, + Scm: "git", + } + + if opts.Private { + repoOpts.IsPrivate = fmt.Sprintf("%t", opts.Private) + } + + repo, err := g.Repository.Get(repoOpts) + if err != nil { + return "", err + } + + return repo.Mainbranch.Name, nil + +} + +func (g *bitbucket) GetAuthor(_ context.Context) (username, email string, err error) { + authUser, err := g.getAuthenticatedUser() + if err != nil { + return + } + + username = authUser.Username + + authUserEmail, err := g.getAuthenticatedUserEmail() + if err != nil || authUserEmail == "" { + email = authUser.Username + return + } + + email = authUserEmail + + return +} + +func (g *bitbucket) getAuthenticatedUser() (*bb.User, error) { + user, err := g.User.Profile() + + if err != nil { + return nil, err + } + + return user, nil +} + +func (g *bitbucket) getAuthenticatedUserEmail() (string, error) { + emails, err := g.User.Emails() + if err != nil { + return "", err + } + + userEmails := emails.(map[string]interface{}) + var lastEmailInfo map[string]interface{} + + for _, emailValues := range userEmails["values"].([]interface{}) { + emailInfo := emailValues.(map[string]interface{}) + isPrimary := emailInfo["is_primary"].(bool) + isConfirmed := emailInfo["is_confirmed"].(bool) + isLastPrimary, lastExist := lastEmailInfo["is_primary"].(bool) + if isConfirmed && isPrimary { + lastEmailInfo = emailInfo + break + } + + if isPrimary { + lastEmailInfo = emailInfo + } + + if ((lastExist && !isLastPrimary) || !lastExist) && isConfirmed { + lastEmailInfo = emailInfo + } + } + + if email, ok := lastEmailInfo["email"].(string); ok { + return email, nil + } + + return "", nil +} diff --git a/pkg/git/provider_bitbucket_test.go b/pkg/git/provider_bitbucket_test.go new file mode 100644 index 00000000..d993998a --- /dev/null +++ b/pkg/git/provider_bitbucket_test.go @@ -0,0 +1,310 @@ +package git + +import ( + "context" + "fmt" + "testing" + + bbmocks "github.com/argoproj-labs/argocd-autopilot/pkg/git/bitbucket/mocks" + "github.com/golang/mock/gomock" + bb "github.com/ktrysmt/go-bitbucket" + "github.com/stretchr/testify/assert" +) + +func Test_bitbucket_CreateRepository(t *testing.T) { + tests := map[string]struct { + orgRepo string + want string + wantErr string + beforeRepoFn func(*bbmocks.MockbbRepo) + }{ + "Should fail if orgRepo is invalid": { + orgRepo: "invalid", + wantErr: "failed parsing organization and repo from 'invalid'", + }, + "Creates repository under user": { + orgRepo: "username/repoName", + want: "https://username@bitbucket.org/username/repoName.git", + beforeRepoFn: func(c *bbmocks.MockbbRepo) { + createOpts := bb.RepositoryOptions{ + Owner: "username", + RepoSlug: "repoName", + Scm: "git", + IsPrivate: "true", + } + + links := map[string]interface{}{ + "self": map[string]string{ + "href": "https://api.bitbucket.org/2.0/repositories/userName/repoName", + }, + "clone": []interface{}{ + map[string]interface{}{ + "name": "https", + "href": "https://username@bitbucket.org/username/repoName.git", + }, + }, + } + + repo := &bb.Repository{ + Name: "userName", + Links: links, + } + + c.EXPECT().Create(&createOpts). + Times(1). + Return(repo, nil) + }, + }, + + "Creates repository under user but cloneUrl doesnt exist": { + orgRepo: "username/repoName", + wantErr: "failed creating the repository \"repoName\" under \"username\": clone url is empty", + beforeRepoFn: func(c *bbmocks.MockbbRepo) { + createOpts := bb.RepositoryOptions{ + Owner: "username", + RepoSlug: "repoName", + Scm: "git", + IsPrivate: "true", + } + + c.EXPECT().Create(&createOpts). + Times(1). + Return(nil, fmt.Errorf("clone url is empty")) + }, + }, + "Fails if token missing required permissions scopes": { + orgRepo: "username/repoName", + wantErr: "failed creating the repository \"repoName\" under \"username\": 403 Forbidden", + beforeRepoFn: func(c *bbmocks.MockbbRepo) { + createOpts := bb.RepositoryOptions{ + Owner: "username", + RepoSlug: "repoName", + Scm: "git", + IsPrivate: "true", + } + + err := &bb.UnexpectedResponseStatusError{ + Status: "403 Forbidden", + } + + c.EXPECT().Create(&createOpts). + Times(1). + Return(nil, err) + }, + }, + } + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + mockRepoClient := bbmocks.NewMockbbRepo(gomock.NewController(t)) + mockUserClient := bbmocks.NewMockbbUser(gomock.NewController(t)) + if tt.beforeRepoFn != nil { + tt.beforeRepoFn(mockRepoClient) + } + + g := &bitbucket{ + Repository: mockRepoClient, + User: mockUserClient, + } + got, err := g.CreateRepository(context.Background(), tt.orgRepo) + if err != nil || tt.wantErr != "" { + assert.EqualError(t, err, tt.wantErr) + return + } + + assert.Equal(t, tt.want, got) + }) + } +} + +func Test_bitbucket_GetDefaultBranch(t *testing.T) { + tests := map[string]struct { + orgRepo string + want string + wantErr string + beforeRepoFn func(*bbmocks.MockbbRepo) + }{ + "Should fail if orgRepo is invalid": { + orgRepo: "invalid", + wantErr: "failed parsing organization and repo from 'invalid'", + }, + "Should fail if repo Get fails with 404 - not found ": { + orgRepo: "owner/repo", + wantErr: "404 Not Found", + beforeRepoFn: func(c *bbmocks.MockbbRepo) { + err := &bb.UnexpectedResponseStatusError{ + Status: "404 Not Found", + } + getOpts := &bb.RepositoryOptions{ + Owner: "owner", + RepoSlug: "repo", + Scm: "git", + IsPrivate: "true", + } + c.EXPECT().Get(getOpts). + Times(1). + Return(nil, err) + }, + }, + "Should succeed with valid default branch": { + orgRepo: "owner/repo", + want: "master", + beforeRepoFn: func(c *bbmocks.MockbbRepo) { + res := &bb.Repository{ + Mainbranch: bb.RepositoryBranch{ + Type: "branch", + Name: "master", + }, + } + getOpts := &bb.RepositoryOptions{ + Owner: "owner", + RepoSlug: "repo", + Scm: "git", + IsPrivate: "true", + } + c.EXPECT().Get(getOpts). + Times(1). + Return(res, nil) + }, + }, + } + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + mockRepoClient := bbmocks.NewMockbbRepo(gomock.NewController(t)) + mockUserClient := bbmocks.NewMockbbUser(gomock.NewController(t)) + if tt.beforeRepoFn != nil { + tt.beforeRepoFn(mockRepoClient) + } + + g := &bitbucket{ + Repository: mockRepoClient, + User: mockUserClient, + } + got, err := g.GetDefaultBranch(context.Background(), tt.orgRepo) + if err != nil || tt.wantErr != "" { + assert.EqualError(t, err, tt.wantErr) + return + } + + assert.Equal(t, tt.want, got) + }) + } +} + +func Test_bitbucket_GetAuthor(t *testing.T) { + tests := map[string]struct { + wantUsername string + wantEmail string + wantErr string + beforeUserFn func(*bbmocks.MockbbUser) + }{ + "Should fail if user GET returns 404": { + wantErr: "404 Not Found", + beforeUserFn: func(c *bbmocks.MockbbUser) { + c.EXPECT().Profile().Times(1). + Return(nil, &bb.UnexpectedResponseStatusError{ + Status: "404 Not Found", + }) + }, + }, + "Should return name and email (primary and confirmed) if available": { + wantUsername: "name", + wantEmail: "name@email", + beforeUserFn: func(c *bbmocks.MockbbUser) { + c.EXPECT().Profile().Times(1).Return(&bb.User{ + Username: "name", + }, nil) + + c.EXPECT().Emails().Times(1).Return(map[string]interface{}{ + "values": []interface{}{ + map[string]interface{}{ + "email": "name2@email", + "is_primary": false, + "is_confirmed": true, + }, + map[string]interface{}{ + "email": "name@email", + "is_primary": true, + "is_confirmed": true, + }, + map[string]interface{}{ + "email": "name3@email", + "is_primary": false, + "is_confirmed": false, + }, + }, + }, nil) + }, + }, + "Should return name and confirmed email in case primary not exist": { + wantUsername: "name", + wantEmail: "name@email", + beforeUserFn: func(c *bbmocks.MockbbUser) { + c.EXPECT().Profile().Times(1).Return(&bb.User{ + Username: "name", + }, nil) + + c.EXPECT().Emails().Times(1).Return(map[string]interface{}{ + "values": []interface{}{ + map[string]interface{}{ + "email": "name2@email", + "is_primary": false, + "is_confirmed": false, + }, + map[string]interface{}{ + "email": "name@email", + "is_primary": false, + "is_confirmed": true, + }, + }, + }, nil) + }, + }, + "Should return name and name as email in case no primary or confirmed email exist": { + wantUsername: "name", + wantEmail: "name", + beforeUserFn: func(c *bbmocks.MockbbUser) { + c.EXPECT().Profile().Times(1).Return(&bb.User{ + Username: "name", + }, nil) + + c.EXPECT().Emails().Times(1).Return(map[string]interface{}{ + "values": []interface{}{ + map[string]interface{}{ + "email": "name2@email", + "is_primary": false, + "is_confirmed": false, + }, + map[string]interface{}{ + "email": "name@email", + "is_primary": false, + "is_confirmed": false, + }, + }, + }, nil) + }, + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + mockRepoClient := bbmocks.NewMockbbRepo(gomock.NewController(t)) + mockUserClient := bbmocks.NewMockbbUser(gomock.NewController(t)) + if tt.beforeUserFn != nil { + tt.beforeUserFn(mockUserClient) + } + + g := &bitbucket{ + Repository: mockRepoClient, + User: mockUserClient, + } + gotUsername, gotEmail, err := g.GetAuthor(context.Background()) + if err != nil || tt.wantErr != "" { + assert.EqualError(t, err, tt.wantErr) + return + } + + assert.Equal(t, tt.wantUsername, gotUsername, "username mismatch") + assert.Equal(t, tt.wantEmail, gotEmail, "email mismatch") + }) + } +} diff --git a/pkg/git/repository_test.go b/pkg/git/repository_test.go index 58fa5b77..b3758253 100644 --- a/pkg/git/repository_test.go +++ b/pkg/git/repository_test.go @@ -1279,7 +1279,7 @@ func TestAddFlags(t *testing.T) { wantedFlags: []flag{ { name: "provider", - usage: "The git provider, one of: azure|bitbucket-server|gitea|github|gitlab", + usage: "The git provider, one of: azure|bitbucket|bitbucket-server|gitea|github|gitlab", }, }, },