Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add Azure DevOps SCM Provider Generator; add branchNormalized to SCM Generator template fields. #9283

Merged
merged 11 commits into from
Jun 15, 2022
Merged
22 changes: 16 additions & 6 deletions applicationset/generators/scm_provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,15 @@ func (g *SCMProviderGenerator) GenerateParams(appSetGenerator *argoprojiov1alpha
if scmError != nil {
return nil, fmt.Errorf("error initializing Bitbucket Server service: %v", scmError)
}
} else if providerConfig.AzureDevOps != nil {
token, err := g.getSecretRef(ctx, providerConfig.AzureDevOps.AccessTokenRef, applicationSetInfo.Namespace)
if err != nil {
return nil, fmt.Errorf("error fetching Azure Devops access token: %v", err)
}
provider, err = scm_provider.NewAzureDevOpsProvider(ctx, token, providerConfig.AzureDevOps.Organization, providerConfig.AzureDevOps.API, providerConfig.AzureDevOps.TeamProject, providerConfig.AzureDevOps.AllBranches)
if err != nil {
return nil, fmt.Errorf("error initializing Azure Devops service: %v", err)
}
} else {
return nil, fmt.Errorf("no SCM provider implementation configured")
}
Expand All @@ -113,12 +122,13 @@ func (g *SCMProviderGenerator) GenerateParams(appSetGenerator *argoprojiov1alpha
params := make([]map[string]string, 0, len(repos))
for _, repo := range repos {
params = append(params, map[string]string{
"organization": repo.Organization,
"repository": repo.Repository,
"url": repo.URL,
"branch": repo.Branch,
"sha": repo.SHA,
"labels": strings.Join(repo.Labels, ","),
"organization": repo.Organization,
"repository": repo.Repository,
"url": repo.URL,
"branch": repo.Branch,
"sha": repo.SHA,
"labels": strings.Join(repo.Labels, ","),
"branchNormalized": sanitizeName(repo.Branch),
})
}
return params, nil
Expand Down
219 changes: 219 additions & 0 deletions applicationset/services/scm_provider/azure_devops.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
package scm_provider

import (
"context"
"fmt"
netUrl "net/url"
"strings"

"github.com/google/uuid"
"github.com/microsoft/azure-devops-go-api/azuredevops"
azureGit "github.com/microsoft/azure-devops-go-api/azuredevops/git"
)

const AZURE_DEVOPS_DEFAULT_URL = "https://dev.azure.com"

type azureDevOpsErrorTypeKeyValuesType struct {
GitRepositoryNotFound string
GitItemNotFound string
}

var AzureDevOpsErrorsTypeKeyValues = azureDevOpsErrorTypeKeyValuesType{
GitRepositoryNotFound: "GitRepositoryNotFoundException",
GitItemNotFound: "GitItemNotFoundException",
}

type AzureDevOpsClientFactory interface {
// Returns an Azure Devops Client interface.
GetClient(ctx context.Context) (azureGit.Client, error)
}

type devopsFactoryImpl struct {
connection *azuredevops.Connection
}

func (factory *devopsFactoryImpl) GetClient(ctx context.Context) (azureGit.Client, error) {
gitClient, err := azureGit.NewClient(ctx, factory.connection)
if err != nil {
return nil, fmt.Errorf("failed to get new Azure DevOps git client for SCM generator: %w", err)
}
return gitClient, nil
}

// Contains Azure Devops REST API implementation of SCMProviderService.
// See https://docs.microsoft.com/en-us/rest/api/azure/devops

type AzureDevOpsProvider struct {
organization string
teamProject string
accessToken string
clientFactory AzureDevOpsClientFactory
allBranches bool
}

var _ SCMProviderService = &AzureDevOpsProvider{}
var _ AzureDevOpsClientFactory = &devopsFactoryImpl{}

func NewAzureDevOpsProvider(ctx context.Context, accessToken string, org string, url string, project string, allBranches bool) (*AzureDevOpsProvider, error) {
if accessToken == "" {
return nil, fmt.Errorf("no access token provided")
}

devOpsURL, err := getValidDevOpsURL(url, org)

if err != nil {
return nil, err
}

connection := azuredevops.NewPatConnection(devOpsURL, accessToken)

return &AzureDevOpsProvider{organization: org, teamProject: project, accessToken: accessToken, clientFactory: &devopsFactoryImpl{connection: connection}, allBranches: allBranches}, nil
}

func (g *AzureDevOpsProvider) ListRepos(ctx context.Context, cloneProtocol string) ([]*Repository, error) {
gitClient, err := g.clientFactory.GetClient(ctx)
if err != nil {
return nil, fmt.Errorf("failed to get Azure DevOps client: %w", err)
}
getRepoArgs := azureGit.GetRepositoriesArgs{Project: &g.teamProject}
azureRepos, err := gitClient.GetRepositories(ctx, getRepoArgs)

if err != nil {
return nil, err
}
repos := []*Repository{}
for _, azureRepo := range *azureRepos {
if azureRepo.Name == nil || azureRepo.DefaultBranch == nil || azureRepo.RemoteUrl == nil || azureRepo.Id == nil {
continue
}
repos = append(repos, &Repository{
Organization: g.organization,
Repository: *azureRepo.Name,
URL: *azureRepo.RemoteUrl,
Branch: *azureRepo.DefaultBranch,
Labels: []string{},
RepositoryId: *azureRepo.Id,
})
}

return repos, nil
}

func (g *AzureDevOpsProvider) RepoHasPath(ctx context.Context, repo *Repository, path string) (bool, error) {
gitClient, err := g.clientFactory.GetClient(ctx)
if err != nil {
return false, fmt.Errorf("failed to get Azure DevOps client: %w", err)
}

var repoId string
if uuid, isUuid := repo.RepositoryId.(uuid.UUID); isUuid { //most likely an UUID, but do type-safe check anyway. Do %v fallback if not expected type.
repoId = uuid.String()
} else {
repoId = fmt.Sprintf("%v", repo.RepositoryId)
}

branchName := repo.Branch
getItemArgs := azureGit.GetItemArgs{RepositoryId: &repoId, Project: &g.teamProject, Path: &path, VersionDescriptor: &azureGit.GitVersionDescriptor{Version: &branchName}}
_, err = gitClient.GetItem(ctx, getItemArgs)

if err != nil {
if wrappedError, isWrappedError := err.(azuredevops.WrappedError); isWrappedError && wrappedError.TypeKey != nil {
if *wrappedError.TypeKey == AzureDevOpsErrorsTypeKeyValues.GitItemNotFound {
return false, nil
}
}

return false, fmt.Errorf("failed to check for path existence in Azure DevOps: %w", err)
}

return true, nil
}

func (g *AzureDevOpsProvider) GetBranches(ctx context.Context, repo *Repository) ([]*Repository, error) {
gitClient, err := g.clientFactory.GetClient(ctx)
if err != nil {
return nil, fmt.Errorf("failed to get Azure DevOps client: %w", err)
}

repos := []*Repository{}

if !g.allBranches {
defaultBranchName := strings.Replace(repo.Branch, "refs/heads/", "", 1) //Azure DevOps returns default branch info like 'refs/heads/main', but does not support branch lookup of this format.
getBranchArgs := azureGit.GetBranchArgs{RepositoryId: &repo.Repository, Project: &g.teamProject, Name: &defaultBranchName}
branchResult, err := gitClient.GetBranch(ctx, getBranchArgs)
if err != nil {
if wrappedError, isWrappedError := err.(azuredevops.WrappedError); isWrappedError && wrappedError.TypeKey != nil {
if *wrappedError.TypeKey == AzureDevOpsErrorsTypeKeyValues.GitRepositoryNotFound {
return repos, nil
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if this error should be bubbled up. Is this ignored because it applies to disabled repos? If it applies to more than than, should we log a warning?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The GitRepository returned from the Azure DevOps Golang library doesn't include info indicating if a repo is disabled, but the REST API it calls, does.

When retrieving branches for a disabled repo, the WrappedError contains the GitRepositoryNotFoundException text. For this situation, I chose to ignore the error and just return an empty slice.

I'll need to give this some thought; The GetRepositories call is quite simple, so a GET could be issued directly to the REST API... Might work, but it seems like a suboptimal solution.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, reinventing the wheel there would stink. I'm okay merging as-is. I don't think this will catch transient errors and accidentally delete Apps or anything nefarious like that.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks :)
The feature branch has one conflict in go.mod and some 129 commits behind master. I'll do a merge and push asap.

}
}
return nil, fmt.Errorf("could not get default branch %v (%v) from repository %v: %w", defaultBranchName, repo.Branch, repo.Repository, err)
}

if branchResult.Name == nil || branchResult.Commit == nil {
return nil, fmt.Errorf("invalid branch result after requesting branch %v from repository %v", repo.Branch, repo.Repository)
}

repos = append(repos, &Repository{
Branch: *branchResult.Name,
SHA: *branchResult.Commit.CommitId,
Organization: repo.Organization,
Repository: repo.Repository,
URL: repo.URL,
Labels: []string{},
RepositoryId: repo.RepositoryId,
})

return repos, nil
}

getBranchesRequest := azureGit.GetBranchesArgs{RepositoryId: &repo.Repository, Project: &g.teamProject}
branches, err := gitClient.GetBranches(ctx, getBranchesRequest)
if err != nil {
if wrappedError, isWrappedError := err.(azuredevops.WrappedError); isWrappedError && wrappedError.TypeKey != nil {
if *wrappedError.TypeKey == AzureDevOpsErrorsTypeKeyValues.GitRepositoryNotFound {
return repos, nil
}
}
return nil, fmt.Errorf("failed getting branches from repository %v, project %v: %w", repo.Repository, g.teamProject, err)
}

if branches == nil {
return nil, fmt.Errorf("got empty branch result from repository %v, project %v: %w", repo.Repository, g.teamProject, err)
}

for _, azureBranch := range *branches {
repos = append(repos, &Repository{
Branch: *azureBranch.Name,
SHA: *azureBranch.Commit.CommitId,
Organization: repo.Organization,
Repository: repo.Repository,
URL: repo.URL,
Labels: []string{},
RepositoryId: repo.RepositoryId,
})
}

return repos, nil
}

func getValidDevOpsURL(url string, org string) (string, error) {
if url == "" {
url = AZURE_DEVOPS_DEFAULT_URL
}
separator := ""
if !strings.HasSuffix(url, "/") {
separator = "/"
}

devOpsURL := fmt.Sprintf("%s%s%s", url, separator, org)

urlCheck, err := netUrl.ParseRequestURI(devOpsURL)

if err != nil {
return "", fmt.Errorf("got an invalid URL for the Azure SCM generator: %w", err)
}

ret := urlCheck.String()
return ret, nil
}
Loading