diff --git a/docs/Getting-Started.md b/docs/Getting-Started.md index 08d5a1d7..de22ac06 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, gitlab and gitea as SCM providers. +Autopilot currently support github, gitlab, azure devops, 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 18612d3a..e3919acd 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|gitlab + --provider string The git provider, one of: azure|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 5539feba..df375681 100644 --- a/go.mod +++ b/go.mod @@ -13,6 +13,8 @@ require ( github.com/go-git/go-git/v5 v5.4.1 github.com/gobuffalo/packr v1.30.1 github.com/google/go-github/v35 v35.3.0 + github.com/google/uuid v1.3.0 // indirect + github.com/microsoft/azure-devops-go-api/azuredevops v1.0.0-b5 github.com/sirupsen/logrus v1.8.1 github.com/spf13/cobra v1.1.3 github.com/spf13/pflag v1.0.5 diff --git a/go.sum b/go.sum index 2a0e4bfe..c5ce4081 100644 --- a/go.sum +++ b/go.sum @@ -442,8 +442,9 @@ github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaU github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= github.com/google/uuid v1.0.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/google/uuid v1.1.2 h1:EVhdT+1Kseyi1/pUmXKaFxYsDNy9RQYkMWRH68J/W7Y= github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= +github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= github.com/googleapis/gnostic v0.4.1/go.mod h1:LRhVm6pbyptWbWbuZ38d1eyptfvIytN3ir6b65WBswg= @@ -615,6 +616,8 @@ github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5 github.com/matttproud/golang_protobuf_extensions v1.0.2-0.20181231171920-c182affec369 h1:I0XW9+e1XWDxdcEniV4rQAIOPUGDq67JSCiRCgGCZLI= github.com/matttproud/golang_protobuf_extensions v1.0.2-0.20181231171920-c182affec369/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4= github.com/mholt/certmagic v0.6.2-0.20190624175158-6a42ef9fe8c2/go.mod h1:g4cOPxcjV0oFq3qwpjSA30LReKD8AoIfwAY9VvG35NY= +github.com/microsoft/azure-devops-go-api/azuredevops v1.0.0-b5 h1:YH424zrwLTlyHSH/GzLMJeu5zhYVZSx5RQxGKm1h96s= +github.com/microsoft/azure-devops-go-api/azuredevops v1.0.0-b5/go.mod h1:PoGiBqKSQK1vIfQ+yVaFcGjDySHvym6FM1cNYnwzbrY= github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg= github.com/miekg/dns v1.1.3/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg= github.com/miekg/dns v1.1.35/go.mod h1:KNUDUusw/aVsxyTYZM1oqvCicbwhgbNgztCETuNZ7xM= diff --git a/pkg/git/ado/mocks/ado_client.go b/pkg/git/ado/mocks/ado_client.go new file mode 100644 index 00000000..0ae56874 --- /dev/null +++ b/pkg/git/ado/mocks/ado_client.go @@ -0,0 +1,38 @@ +// Code generated by mockery (devel). DO NOT EDIT. + +package mocks + +import ( + context "context" + + git "github.com/microsoft/azure-devops-go-api/azuredevops/git" + mock "github.com/stretchr/testify/mock" +) + +// AdoClient is an autogenerated mock type for the AdoClient type +type AdoClient struct { + mock.Mock +} + +// CreateRepository provides a mock function with given fields: _a0, _a1 +func (_m *AdoClient) CreateRepository(_a0 context.Context, _a1 git.CreateRepositoryArgs) (*git.GitRepository, error) { + ret := _m.Called(_a0, _a1) + + var r0 *git.GitRepository + if rf, ok := ret.Get(0).(func(context.Context, git.CreateRepositoryArgs) *git.GitRepository); ok { + r0 = rf(_a0, _a1) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*git.GitRepository) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, git.CreateRepositoryArgs) error); ok { + r1 = rf(_a0, _a1) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} diff --git a/pkg/git/ado/mocks/ado_url.go b/pkg/git/ado/mocks/ado_url.go new file mode 100644 index 00000000..8da85743 --- /dev/null +++ b/pkg/git/ado/mocks/ado_url.go @@ -0,0 +1,24 @@ +// Code generated by mockery (devel). DO NOT EDIT. + +package mocks + +import mock "github.com/stretchr/testify/mock" + +// AdoUrl is an autogenerated mock type for the AdoUrl type +type AdoUrl struct { + mock.Mock +} + +// GetProjectName provides a mock function with given fields: +func (_m *AdoUrl) GetProjectName() string { + ret := _m.Called() + + var r0 string + if rf, ok := ret.Get(0).(func() string); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(string) + } + + return r0 +} diff --git a/pkg/git/provider.go b/pkg/git/provider.go index 8d93256d..6db61497 100644 --- a/pkg/git/provider.go +++ b/pkg/git/provider.go @@ -54,6 +54,7 @@ var supportedProviders = map[string]func(*ProviderOptions) (Provider, error){ "github": newGithub, "gitea": newGitea, "gitlab": newGitlab, + Azure: newAdo, } // New creates a new git provider @@ -72,6 +73,6 @@ func Providers() []string { res = append(res, p) } - sort.Strings(res) // must sort the providers by name, otherwise the codegen is not determenistic + sort.Strings(res) // must sort the providers by name, otherwise the codegen is not deterministic return res } diff --git a/pkg/git/provider_ado.go b/pkg/git/provider_ado.go new file mode 100644 index 00000000..074f06cd --- /dev/null +++ b/pkg/git/provider_ado.go @@ -0,0 +1,106 @@ +package git + +import ( + "context" + "fmt" + "github.com/microsoft/azure-devops-go-api/azuredevops" + ado "github.com/microsoft/azure-devops-go-api/azuredevops/git" + "net/url" + "strings" + "time" +) + +//go:generate mockery --name Ado* --output ado/mocks --case snake +type ( + AdoClient interface { + CreateRepository(context.Context, ado.CreateRepositoryArgs) (*ado.GitRepository, error) + } + + AdoUrl interface { + GetProjectName() string + } + + adoGit struct { + adoClient AdoClient + opts *ProviderOptions + adoUrl AdoUrl + } + + adoGitUrl struct { + loginUrl string + subscription string + projectName string + } +) + +const Azure = "azure" +const AzureHostName = "dev.azure" +const timeoutTime = 10 * time.Second + +func newAdo(opts *ProviderOptions) (Provider, error) { + adoUrl, err := parseAdoUrl(opts.Host) + if err != nil { + return nil, err + } + connection := azuredevops.NewPatConnection(adoUrl.loginUrl, opts.Auth.Password) + ctx, cancel := context.WithTimeout(context.Background(), timeoutTime) + defer cancel() + // FYI: ado also has a "core" client that can be used to update project, teams, and other ADO constructs + gitClient, err := ado.NewClient(ctx, connection) + if err != nil { + return nil, err + } + + return &adoGit{ + adoClient: gitClient, + opts: opts, + adoUrl: adoUrl, + }, nil +} + +func (g *adoGit) CreateRepository(ctx context.Context, opts *CreateRepoOptions) (string, error) { + if opts.Name == "" { + return "", fmt.Errorf("name needs to be provided to create an azure devops repository. "+ + "name: '%s'", opts.Name) + } + gitRepoToCreate := &ado.GitRepositoryCreateOptions{ + Name: &opts.Name, + } + project := g.adoUrl.GetProjectName() + createRepositoryArgs := ado.CreateRepositoryArgs{ + GitRepositoryToCreate: gitRepoToCreate, + Project: &project, + } + repository, err := g.adoClient.CreateRepository(ctx, createRepositoryArgs) + if err != nil { + return "", err + } + return *repository.RemoteUrl, nil +} + +func (a *adoGitUrl) GetProjectName() string { + return a.projectName +} + +// getLoginUrl parses a URL to retrieve the subscription and project name +func parseAdoUrl(host string) (*adoGitUrl, error) { + u, err := url.Parse(host) + if err != nil { + return nil, err + } + var sub, project string + path := strings.Split(u.Path, "/") + if len(path) < 5 { + return nil, fmt.Errorf("unable to parse Azure DevOps url") + } else { + // 1 since the path starts with a slash + sub = path[1] + project = path[2] + } + loginUrl := fmt.Sprintf("%s://%s/%s", u.Scheme, u.Host, sub) + return &adoGitUrl{ + loginUrl: loginUrl, + subscription: sub, + projectName: project, + }, nil +} diff --git a/pkg/git/provider_ado_test.go b/pkg/git/provider_ado_test.go new file mode 100644 index 00000000..93828cc2 --- /dev/null +++ b/pkg/git/provider_ado_test.go @@ -0,0 +1,130 @@ +package git + +import ( + "context" + "errors" + "fmt" + adoMock "github.com/argoproj-labs/argocd-autopilot/pkg/git/ado/mocks" + ado "github.com/microsoft/azure-devops-go-api/azuredevops/git" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "testing" +) + +func Test_adoGit_CreateRepository(t *testing.T) { + remoteURL := "https://dev.azure.com/SUB/PROJECT/_git/REPO" + emptyFunc := func(client *adoMock.AdoClient, url *adoMock.AdoUrl) {} + type args struct { + ctx context.Context + opts *CreateRepoOptions + } + tests := []struct { + name string + mockClient func(client *adoMock.AdoClient, url *adoMock.AdoUrl) + args args + want string + wantErr assert.ErrorAssertionFunc + }{ + {name: "Empty Name", mockClient: emptyFunc, args: args{ + ctx: context.TODO(), + opts: &CreateRepoOptions{ + Owner: "rumstead", + Name: "", + }, + }, want: "", wantErr: func(t assert.TestingT, err error, i ...interface{}) bool { + return true + }}, + {name: "Failure creating repo", mockClient: func(client *adoMock.AdoClient, url *adoMock.AdoUrl) { + client.On("CreateRepository", context.TODO(), + mock.AnythingOfType("CreateRepositoryArgs")). + Return(nil, errors.New("ah an error")) + url.On("GetProjectName").Return("blah") + }, args: args{ + ctx: context.TODO(), + opts: &CreateRepoOptions{ + Owner: "rumstead", + Name: "name", + }, + }, want: "", wantErr: func(t assert.TestingT, err error, i ...interface{}) bool { + return true + }}, + {name: "Success creating repo", mockClient: func(client *adoMock.AdoClient, url *adoMock.AdoUrl) { + url.On("GetProjectName").Return("PROJECT") + client.On("CreateRepository", context.TODO(), mock.AnythingOfType("CreateRepositoryArgs")).Return(&ado.GitRepository{ + Links: nil, + DefaultBranch: nil, + Id: nil, + IsFork: nil, + Name: nil, + ParentRepository: nil, + Project: nil, + RemoteUrl: &remoteURL, + Size: nil, + SshUrl: nil, + Url: nil, + ValidRemoteUrls: nil, + WebUrl: nil, + }, nil) + }, args: args{ + ctx: context.TODO(), + opts: &CreateRepoOptions{ + Owner: "rumstead", + Name: "name", + }, + }, want: "https://dev.azure.com/SUB/PROJECT/_git/REPO", wantErr: func(t assert.TestingT, err error, i ...interface{}) bool { + return false + }}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockClient := &adoMock.AdoClient{} + mockUrl := &adoMock.AdoUrl{} + tt.mockClient(mockClient, mockUrl) + g := &adoGit{ + adoClient: mockClient, + adoUrl: mockUrl, + } + got, err := g.CreateRepository(tt.args.ctx, tt.args.opts) + if !tt.wantErr(t, err, fmt.Sprintf("CreateRepository(%v, %v)", tt.args.ctx, tt.args.opts)) { + return + } + assert.Equalf(t, tt.want, got, "CreateRepository(%v, %v)", tt.args.ctx, tt.args.opts) + }) + } +} + +func Test_parseAdoUrl(t *testing.T) { + type args struct { + host string + } + tests := []struct { + name string + args args + want *adoGitUrl + wantErr assert.ErrorAssertionFunc + }{ + {name: "Invalid URL", args: args{host: "https://dev.azure.com"}, want: nil, wantErr: func(t assert.TestingT, err error, i ...interface{}) bool { + return true + }}, + // url taking from the url_test in the url/net module + {name: "Parse Error", args: args{host: "http://[fe80::%31]:8080/"}, want: nil, wantErr: func(t assert.TestingT, err error, i ...interface{}) bool { + return true + }}, + {name: "Parse URL", args: args{host: "https://dev.azure.com/SUB/PROJECT/_git/REPO "}, want: &adoGitUrl{ + loginUrl: "https://dev.azure.com/SUB", + subscription: "SUB", + projectName: "PROJECT", + }, wantErr: func(t assert.TestingT, err error, i ...interface{}) bool { + return false + }}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := parseAdoUrl(tt.args.host) + if !tt.wantErr(t, err, fmt.Sprintf("parseAdoUrl(%v)", tt.args.host)) { + return + } + assert.Equalf(t, tt.want, got, "parseAdoUrl(%v)", tt.args.host) + }) + } +} diff --git a/pkg/git/provider_gitea.go b/pkg/git/provider_gitea.go index 63459661..b87e33f9 100644 --- a/pkg/git/provider_gitea.go +++ b/pkg/git/provider_gitea.go @@ -34,7 +34,7 @@ func newGitea(opts *ProviderOptions) (Provider, error) { return g, nil } -func (g *gitea) CreateRepository(ctx context.Context, opts *CreateRepoOptions) (string, error) { +func (g *gitea) CreateRepository(_ context.Context, opts *CreateRepoOptions) (string, error) { authUser, res, err := g.client.GetMyUserInfo() if err != nil { if res.StatusCode == 401 { diff --git a/pkg/git/provider_gitlab.go b/pkg/git/provider_gitlab.go index 40ebbd67..1ce0a68b 100644 --- a/pkg/git/provider_gitlab.go +++ b/pkg/git/provider_gitlab.go @@ -46,7 +46,7 @@ func newGitlab(opts *ProviderOptions) (Provider, error) { return g, nil } -func (g *gitlab) CreateRepository(ctx context.Context, opts *CreateRepoOptions) (string, error) { +func (g *gitlab) CreateRepository(_ context.Context, opts *CreateRepoOptions) (string, error) { authUser, res, err := g.client.CurrentUser() if err != nil { if res.StatusCode == 401 { diff --git a/pkg/git/repository.go b/pkg/git/repository.go index 01d3aefc..648e7fec 100644 --- a/pkg/git/repository.go +++ b/pkg/git/repository.go @@ -372,7 +372,11 @@ var createRepo = func(ctx context.Context, opts *CloneOptions) (string, error) { return "", err } - providerType = strings.TrimSuffix(u.Hostname(), ".com") + if strings.Contains(u.Hostname(), AzureHostName) { + providerType = Azure + } else { + providerType = strings.TrimSuffix(u.Hostname(), ".com") + } log.G(ctx).Warnf("--provider not specified, assuming provider from url: %s", providerType) } @@ -385,18 +389,34 @@ var createRepo = func(ctx context.Context, opts *CloneOptions) (string, error) { return "", fmt.Errorf("failed to create the repository, you can try to manually create it before trying again: %w", err) } + switch providerType { + case Azure: + return p.CreateRepository(ctx, &CreateRepoOptions{ + Owner: "", + Name: orgRepo, + }) + default: + repoOptions, err := getDefaultRepoOptions(orgRepo) + if err != nil { + return "", nil + } + return p.CreateRepository(ctx, repoOptions) + } +} + +func getDefaultRepoOptions(orgRepo string) (*CreateRepoOptions, error) { s := strings.Split(orgRepo, "/") if len(s) < 2 { - return "", fmt.Errorf("failed parsing organization and repo from '%s'", orgRepo) + return nil, fmt.Errorf("failed parsing organization and repo from '%s'", orgRepo) } owner := strings.Join(s[:len(s)-1], "/") name := s[len(s)-1] - return p.CreateRepository(ctx, &CreateRepoOptions{ + return &CreateRepoOptions{ Owner: owner, Name: name, Private: true, - }) + }, nil } var initRepo = func(ctx context.Context, opts *CloneOptions) (*repo, error) { diff --git a/pkg/git/repository_test.go b/pkg/git/repository_test.go index fcfe2ef7..b4ff260c 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|gitlab", + usage: "The git provider, one of: azure|gitea|github|gitlab", }, }, },