github.com/argoproj/argo-cd/v2@v2.10.9/applicationset/services/scm_provider/aws_codecommit.go (about)

     1  package scm_provider
     2  
     3  import (
     4  	"context"
     5  	"fmt"
     6  	"github.com/aws/aws-sdk-go/aws/request"
     7  	pathpkg "path"
     8  	"path/filepath"
     9  	"strings"
    10  
    11  	application "github.com/argoproj/argo-cd/v2/pkg/apis/application/v1alpha1"
    12  	"github.com/aws/aws-sdk-go/aws"
    13  	"github.com/aws/aws-sdk-go/aws/arn"
    14  	"github.com/aws/aws-sdk-go/aws/awserr"
    15  	"github.com/aws/aws-sdk-go/aws/credentials/stscreds"
    16  	"github.com/aws/aws-sdk-go/aws/session"
    17  	"github.com/aws/aws-sdk-go/service/codecommit"
    18  	"github.com/aws/aws-sdk-go/service/resourcegroupstaggingapi"
    19  	log "github.com/sirupsen/logrus"
    20  	"golang.org/x/exp/maps"
    21  	"k8s.io/utils/strings/slices"
    22  )
    23  
    24  const (
    25  	resourceTypeCodeCommitRepository = "codecommit:repository"
    26  	prefixGitUrlHttps                = "https://git-codecommit."
    27  	prefixGitUrlHttpsFIPS            = "https://git-codecommit-fips."
    28  )
    29  
    30  // AWSCodeCommitClient is a lean facade to the codecommitiface.CodeCommitAPI
    31  // it helps to reduce the mockery generated code.
    32  type AWSCodeCommitClient interface {
    33  	ListRepositoriesWithContext(aws.Context, *codecommit.ListRepositoriesInput, ...request.Option) (*codecommit.ListRepositoriesOutput, error)
    34  	GetRepositoryWithContext(aws.Context, *codecommit.GetRepositoryInput, ...request.Option) (*codecommit.GetRepositoryOutput, error)
    35  	ListBranchesWithContext(aws.Context, *codecommit.ListBranchesInput, ...request.Option) (*codecommit.ListBranchesOutput, error)
    36  	GetFolderWithContext(aws.Context, *codecommit.GetFolderInput, ...request.Option) (*codecommit.GetFolderOutput, error)
    37  }
    38  
    39  // AWSTaggingClient is a lean facade to the resourcegroupstaggingapiiface.ResourceGroupsTaggingAPIAPI
    40  // it helps to reduce the mockery generated code.
    41  type AWSTaggingClient interface {
    42  	GetResourcesWithContext(aws.Context, *resourcegroupstaggingapi.GetResourcesInput, ...request.Option) (*resourcegroupstaggingapi.GetResourcesOutput, error)
    43  }
    44  
    45  type AWSCodeCommitProvider struct {
    46  	codeCommitClient AWSCodeCommitClient
    47  	taggingClient    AWSTaggingClient
    48  	tagFilters       []*application.TagFilter
    49  	allBranches      bool
    50  }
    51  
    52  func NewAWSCodeCommitProvider(ctx context.Context, tagFilters []*application.TagFilter, role string, region string, allBranches bool) (*AWSCodeCommitProvider, error) {
    53  	taggingClient, codeCommitClient, err := createAWSDiscoveryClients(ctx, role, region)
    54  	if err != nil {
    55  		return nil, err
    56  	}
    57  	return &AWSCodeCommitProvider{
    58  		codeCommitClient: codeCommitClient,
    59  		taggingClient:    taggingClient,
    60  		tagFilters:       tagFilters,
    61  		allBranches:      allBranches,
    62  	}, nil
    63  }
    64  
    65  func (p *AWSCodeCommitProvider) ListRepos(ctx context.Context, cloneProtocol string) ([]*Repository, error) {
    66  	repos := make([]*Repository, 0)
    67  
    68  	repoNames, err := p.listRepoNames(ctx)
    69  	if err != nil {
    70  		return nil, fmt.Errorf("failed to list codecommit repository: %w", err)
    71  	}
    72  
    73  	for _, repoName := range repoNames {
    74  		repo, err := p.codeCommitClient.GetRepositoryWithContext(ctx, &codecommit.GetRepositoryInput{
    75  			RepositoryName: aws.String(repoName),
    76  		})
    77  		if err != nil {
    78  			// we don't want to skip at this point. It's a valid repo, we don't want to have flapping Application on an AWS outage.
    79  			return nil, fmt.Errorf("failed to get codecommit repository: %w", err)
    80  		}
    81  		if repo == nil || repo.RepositoryMetadata == nil {
    82  			// unlikely to happen, but just in case to protect nil pointer dereferences.
    83  			log.Warnf("codecommit returned invalid response for repository %s, skipped", repoName)
    84  			continue
    85  		}
    86  		if aws.StringValue(repo.RepositoryMetadata.DefaultBranch) == "" {
    87  			// if a codecommit repo doesn't have default branch, it's uninitialized. not going to bother with it.
    88  			log.Warnf("repository %s does not have default branch, skipped", repoName)
    89  			continue
    90  		}
    91  		var url string
    92  		switch cloneProtocol {
    93  		// default to SSH if unspecified (i.e. if "").
    94  		case "", "ssh":
    95  			url = aws.StringValue(repo.RepositoryMetadata.CloneUrlSsh)
    96  		case "https":
    97  			url = aws.StringValue(repo.RepositoryMetadata.CloneUrlHttp)
    98  		case "https-fips":
    99  			url, err = getCodeCommitFIPSEndpoint(aws.StringValue(repo.RepositoryMetadata.CloneUrlHttp))
   100  			if err != nil {
   101  				return nil, fmt.Errorf("https-fips is provided but repoUrl can't be transformed to FIPS endpoint: %w", err)
   102  			}
   103  		default:
   104  			return nil, fmt.Errorf("unknown clone protocol for codecommit %v", cloneProtocol)
   105  		}
   106  		repos = append(repos, &Repository{
   107  			// there's no "organization" level at codecommit.
   108  			// we are just using AWS accountId for now.
   109  			Organization: aws.StringValue(repo.RepositoryMetadata.AccountId),
   110  			Repository:   aws.StringValue(repo.RepositoryMetadata.RepositoryName),
   111  			URL:          url,
   112  			Branch:       aws.StringValue(repo.RepositoryMetadata.DefaultBranch),
   113  			// we could propagate repo tag keys, but without value not sure if it's any useful.
   114  			Labels:       []string{},
   115  			RepositoryId: aws.StringValue(repo.RepositoryMetadata.RepositoryId),
   116  		})
   117  	}
   118  
   119  	return repos, nil
   120  }
   121  
   122  func (p *AWSCodeCommitProvider) RepoHasPath(ctx context.Context, repo *Repository, path string) (bool, error) {
   123  	// we use GetFolder instead of GetFile here because GetFile always downloads the full blob which has scalability problem.
   124  	// GetFolder is slightly less concerning.
   125  
   126  	path = toAbsolutePath(path)
   127  	// shortcut: if it's root folder ('/'), we always return true.
   128  	if path == "/" {
   129  		return true, nil
   130  	}
   131  	// here we are sure it's not root folder, strip the suffix for easier comparison.
   132  	path = strings.TrimSuffix(path, "/")
   133  
   134  	// we always get the parent folder, so we could support both submodule, file, symlink and folder cases.
   135  	parentPath := pathpkg.Dir(path)
   136  	basePath := pathpkg.Base(path)
   137  
   138  	input := &codecommit.GetFolderInput{
   139  		CommitSpecifier: aws.String(repo.Branch),
   140  		FolderPath:      aws.String(parentPath),
   141  		RepositoryName:  aws.String(repo.Repository),
   142  	}
   143  	output, err := p.codeCommitClient.GetFolderWithContext(ctx, input)
   144  	if err != nil {
   145  		if hasAwsError(err,
   146  			codecommit.ErrCodeRepositoryDoesNotExistException,
   147  			codecommit.ErrCodeCommitDoesNotExistException,
   148  			codecommit.ErrCodeFolderDoesNotExistException,
   149  		) {
   150  			return false, nil
   151  		}
   152  		// unhandled exception, propagate out
   153  		return false, err
   154  	}
   155  
   156  	// anything that matches.
   157  	for _, submodule := range output.SubModules {
   158  		if basePath == aws.StringValue(submodule.RelativePath) {
   159  			return true, nil
   160  		}
   161  	}
   162  	for _, subpath := range output.SubFolders {
   163  		if basePath == aws.StringValue(subpath.RelativePath) {
   164  			return true, nil
   165  		}
   166  	}
   167  	for _, subpath := range output.Files {
   168  		if basePath == aws.StringValue(subpath.RelativePath) {
   169  			return true, nil
   170  		}
   171  	}
   172  	for _, subpath := range output.SymbolicLinks {
   173  		if basePath == aws.StringValue(subpath.RelativePath) {
   174  			return true, nil
   175  		}
   176  	}
   177  	return false, nil
   178  }
   179  
   180  func (p *AWSCodeCommitProvider) GetBranches(ctx context.Context, repo *Repository) ([]*Repository, error) {
   181  	repos := make([]*Repository, 0)
   182  	if !p.allBranches {
   183  		output, err := p.codeCommitClient.GetRepositoryWithContext(ctx, &codecommit.GetRepositoryInput{
   184  			RepositoryName: aws.String(repo.Repository),
   185  		})
   186  		if err != nil {
   187  			return nil, err
   188  		}
   189  		repos = append(repos, &Repository{
   190  			Organization: repo.Organization,
   191  			Repository:   repo.Repository,
   192  			URL:          repo.URL,
   193  			Branch:       aws.StringValue(output.RepositoryMetadata.DefaultBranch),
   194  			RepositoryId: repo.RepositoryId,
   195  			Labels:       repo.Labels,
   196  			// getting SHA of the branch requires a separate GetBranch call.
   197  			// too expensive. for now, we just don't support it.
   198  			// SHA:          "",
   199  		})
   200  	} else {
   201  		input := &codecommit.ListBranchesInput{
   202  			RepositoryName: aws.String(repo.Repository),
   203  		}
   204  		for {
   205  			output, err := p.codeCommitClient.ListBranchesWithContext(ctx, input)
   206  			if err != nil {
   207  				return nil, err
   208  			}
   209  			for _, branch := range output.Branches {
   210  				repos = append(repos, &Repository{
   211  					Organization: repo.Organization,
   212  					Repository:   repo.Repository,
   213  					URL:          repo.URL,
   214  					Branch:       aws.StringValue(branch),
   215  					RepositoryId: repo.RepositoryId,
   216  					Labels:       repo.Labels,
   217  					// getting SHA of the branch requires a separate GetBranch call.
   218  					// too expensive. for now, we just don't support it.
   219  					// SHA:          "",
   220  				})
   221  			}
   222  			input.NextToken = output.NextToken
   223  			if aws.StringValue(output.NextToken) == "" {
   224  				break
   225  			}
   226  		}
   227  	}
   228  
   229  	return repos, nil
   230  }
   231  
   232  func (p *AWSCodeCommitProvider) listRepoNames(ctx context.Context) ([]string, error) {
   233  	tagFilters := p.getTagFilters()
   234  	repoNames := make([]string, 0)
   235  	var err error
   236  
   237  	if len(tagFilters) < 1 {
   238  		log.Debugf("no tag filer, calling codecommit api to list repos")
   239  		listReposInput := &codecommit.ListRepositoriesInput{}
   240  		var output *codecommit.ListRepositoriesOutput
   241  		for {
   242  			output, err = p.codeCommitClient.ListRepositoriesWithContext(ctx, listReposInput)
   243  			if err != nil {
   244  				break
   245  			}
   246  			for _, repo := range output.Repositories {
   247  				repoNames = append(repoNames, aws.StringValue(repo.RepositoryName))
   248  			}
   249  			listReposInput.NextToken = output.NextToken
   250  			if aws.StringValue(output.NextToken) == "" {
   251  				break
   252  			}
   253  		}
   254  	} else {
   255  		log.Debugf("tag filer is specified, calling tagging api to list repos")
   256  		discoveryInput := &resourcegroupstaggingapi.GetResourcesInput{
   257  			ResourceTypeFilters: aws.StringSlice([]string{resourceTypeCodeCommitRepository}),
   258  			TagFilters:          tagFilters,
   259  		}
   260  		var output *resourcegroupstaggingapi.GetResourcesOutput
   261  		for {
   262  			output, err = p.taggingClient.GetResourcesWithContext(ctx, discoveryInput)
   263  			if err != nil {
   264  				break
   265  			}
   266  			for _, resource := range output.ResourceTagMappingList {
   267  				repoArn := aws.StringValue(resource.ResourceARN)
   268  				log.Debugf("discovered codecommit repo with arn %s", repoArn)
   269  				repoName, extractErr := getCodeCommitRepoName(repoArn)
   270  				if extractErr != nil {
   271  					log.Warnf("discovered codecommit repoArn %s cannot be parsed due to %v", repoArn, err)
   272  					continue
   273  				}
   274  				repoNames = append(repoNames, repoName)
   275  			}
   276  			discoveryInput.PaginationToken = output.PaginationToken
   277  			if aws.StringValue(output.PaginationToken) == "" {
   278  				break
   279  			}
   280  		}
   281  	}
   282  	return repoNames, err
   283  }
   284  
   285  func (p *AWSCodeCommitProvider) getTagFilters() []*resourcegroupstaggingapi.TagFilter {
   286  	filters := make(map[string]*resourcegroupstaggingapi.TagFilter)
   287  	for _, tagFilter := range p.tagFilters {
   288  		filter, hasKey := filters[tagFilter.Key]
   289  		if !hasKey {
   290  			filter = &resourcegroupstaggingapi.TagFilter{
   291  				Key: aws.String(tagFilter.Key),
   292  			}
   293  			filters[tagFilter.Key] = filter
   294  		}
   295  		if tagFilter.Value != "" {
   296  			filter.Values = append(filter.Values, aws.String(tagFilter.Value))
   297  		}
   298  	}
   299  	return maps.Values(filters)
   300  }
   301  
   302  func getCodeCommitRepoName(repoArn string) (string, error) {
   303  	parsedArn, err := arn.Parse(repoArn)
   304  	if err != nil {
   305  		return "", fmt.Errorf("failed to parse codecommit repository ARN: %w", err)
   306  	}
   307  	// see: https://docs.aws.amazon.com/codecommit/latest/userguide/auth-and-access-control-permissions-reference.html
   308  	// arn:aws:codecommit:region:account-id:repository-name
   309  	return parsedArn.Resource, nil
   310  }
   311  
   312  // getCodeCommitFIPSEndpoint transforms provided https:// codecommit URL to a FIPS-compliant endpoint.
   313  // note that the specified region must support FIPS, otherwise the returned URL won't be reachable
   314  // see: https://docs.aws.amazon.com/codecommit/latest/userguide/regions.html#regions-git
   315  func getCodeCommitFIPSEndpoint(repoUrl string) (string, error) {
   316  	if strings.HasPrefix(repoUrl, prefixGitUrlHttpsFIPS) {
   317  		log.Debugf("provided repoUrl %s is already a fips endpoint", repoUrl)
   318  		return repoUrl, nil
   319  	}
   320  	if !strings.HasPrefix(repoUrl, prefixGitUrlHttps) {
   321  		return "", fmt.Errorf("the provided https endpoint isn't recognized, cannot be transformed to FIPS endpoint: %s", repoUrl)
   322  	}
   323  	// we already have the prefix, so we guarantee to replace exactly the prefix only.
   324  	return strings.Replace(repoUrl, prefixGitUrlHttps, prefixGitUrlHttpsFIPS, 1), nil
   325  }
   326  
   327  func hasAwsError(err error, codes ...string) bool {
   328  	if awsErr, ok := err.(awserr.Error); ok {
   329  		return slices.Contains(codes, awsErr.Code())
   330  	}
   331  	return false
   332  }
   333  
   334  // toAbsolutePath transforms a path input to absolute path, as required by AWS CodeCommit
   335  // see https://docs.aws.amazon.com/codecommit/latest/APIReference/API_GetFolder.html
   336  func toAbsolutePath(path string) string {
   337  	if filepath.IsAbs(path) {
   338  		return path
   339  	}
   340  	return filepath.ToSlash(filepath.Join("/", path))
   341  }
   342  
   343  func createAWSDiscoveryClients(_ context.Context, role string, region string) (*resourcegroupstaggingapi.ResourceGroupsTaggingAPI, *codecommit.CodeCommit, error) {
   344  	podSession, err := session.NewSession()
   345  	if err != nil {
   346  		return nil, nil, fmt.Errorf("error creating new AWS pod session: %w", err)
   347  	}
   348  	discoverySession := podSession
   349  	// assume role if provided - this allows cross account CodeCommit repo discovery.
   350  	if role != "" {
   351  		log.Debugf("role %s is provided for AWS CodeCommit discovery", role)
   352  		assumeRoleCreds := stscreds.NewCredentials(podSession, role)
   353  		discoverySession, err = session.NewSession(&aws.Config{
   354  			Credentials: assumeRoleCreds,
   355  		})
   356  		if err != nil {
   357  			return nil, nil, fmt.Errorf("error creating new AWS discovery session: %s", err)
   358  		}
   359  	} else {
   360  		log.Debugf("role is not provided for AWS CodeCommit discovery, using pod role")
   361  	}
   362  	// use region explicitly if provided  - this allows cross region CodeCommit repo discovery.
   363  	if region != "" {
   364  		log.Debugf("region %s is provided for AWS CodeCommit discovery", region)
   365  		discoverySession = discoverySession.Copy(&aws.Config{
   366  			Region: aws.String(region),
   367  		})
   368  	} else {
   369  		log.Debugf("region is not provided for AWS CodeCommit discovery, using pod region")
   370  	}
   371  
   372  	taggingClient := resourcegroupstaggingapi.New(discoverySession)
   373  	codeCommitClient := codecommit.New(discoverySession)
   374  
   375  	return taggingClient, codeCommitClient, nil
   376  }