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 }