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

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