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 }