github.com/argoproj/argo-cd/v3@v3.2.1/applicationset/generators/git.go (about) 1 package generators 2 3 import ( 4 "context" 5 "fmt" 6 "path" 7 "sort" 8 "strconv" 9 "strings" 10 "time" 11 12 "github.com/jeremywohl/flatten" 13 log "github.com/sirupsen/logrus" 14 "k8s.io/apimachinery/pkg/types" 15 "sigs.k8s.io/controller-runtime/pkg/client" 16 "sigs.k8s.io/yaml" 17 18 "github.com/argoproj/argo-cd/v3/applicationset/services" 19 "github.com/argoproj/argo-cd/v3/applicationset/utils" 20 argoprojiov1alpha1 "github.com/argoproj/argo-cd/v3/pkg/apis/application/v1alpha1" 21 "github.com/argoproj/argo-cd/v3/util/gpg" 22 ) 23 24 var _ Generator = (*GitGenerator)(nil) 25 26 type GitGenerator struct { 27 repos services.Repos 28 namespace string 29 } 30 31 // NewGitGenerator creates a new instance of Git Generator 32 func NewGitGenerator(repos services.Repos, controllerNamespace string) Generator { 33 g := &GitGenerator{ 34 repos: repos, 35 namespace: controllerNamespace, 36 } 37 38 return g 39 } 40 41 // GetTemplate returns the ApplicationSetTemplate associated with the Git generator 42 // from the provided ApplicationSetGenerator. This template defines how each 43 // generated Argo CD Application should be rendered. 44 func (g *GitGenerator) GetTemplate(appSetGenerator *argoprojiov1alpha1.ApplicationSetGenerator) *argoprojiov1alpha1.ApplicationSetTemplate { 45 return &appSetGenerator.Git.Template 46 } 47 48 // GetRequeueAfter returns the duration after which the Git generator should be 49 // requeued for reconciliation. If RequeueAfterSeconds is set in the generator spec, 50 // it uses that value. Otherwise, it falls back to a default requeue interval (3 minutes). 51 func (g *GitGenerator) GetRequeueAfter(appSetGenerator *argoprojiov1alpha1.ApplicationSetGenerator) time.Duration { 52 if appSetGenerator.Git.RequeueAfterSeconds != nil { 53 return time.Duration(*appSetGenerator.Git.RequeueAfterSeconds) * time.Second 54 } 55 56 return getDefaultRequeueAfter() 57 } 58 59 // GenerateParams generates a list of parameter maps for the ApplicationSet by evaluating the Git generator's configuration. 60 // It supports both directory-based and file-based Git generators. 61 func (g *GitGenerator) GenerateParams(appSetGenerator *argoprojiov1alpha1.ApplicationSetGenerator, appSet *argoprojiov1alpha1.ApplicationSet, client client.Client) ([]map[string]any, error) { 62 if appSetGenerator == nil { 63 return nil, ErrEmptyAppSetGenerator 64 } 65 66 if appSetGenerator.Git == nil { 67 return nil, ErrEmptyAppSetGenerator 68 } 69 70 noRevisionCache := appSet.RefreshRequired() 71 72 verifyCommit := false 73 74 // When the project field is templated, the contents of the git repo are required to run the git generator and get the templated value, 75 // but git generator cannot be called without verifying the commit signature. 76 // In this case, we skip the signature verification. 77 // If the project is templated, we skip the commit verification 78 if !strings.Contains(appSet.Spec.Template.Spec.Project, "{{") { 79 project := appSet.Spec.Template.Spec.Project 80 appProject := &argoprojiov1alpha1.AppProject{} 81 controllerNamespace := g.namespace 82 if controllerNamespace == "" { 83 controllerNamespace = appSet.Namespace 84 } 85 if err := client.Get(context.TODO(), types.NamespacedName{Name: project, Namespace: controllerNamespace}, appProject); err != nil { 86 return nil, fmt.Errorf("error getting project %s: %w", project, err) 87 } 88 // we need to verify the signature on the Git revision if GPG is enabled 89 verifyCommit = len(appProject.Spec.SignatureKeys) > 0 && gpg.IsGPGEnabled() 90 } 91 92 // If the project field is templated, we cannot resolve the project name, so we pass an empty string to the repo-server. 93 // This means only "globally-scoped" repo credentials can be used for such appsets. 94 project := resolveProjectName(appSet.Spec.Template.Spec.Project) 95 96 var err error 97 var res []map[string]any 98 switch { 99 case len(appSetGenerator.Git.Directories) != 0: 100 res, err = g.generateParamsForGitDirectories(appSetGenerator, noRevisionCache, verifyCommit, appSet.Spec.GoTemplate, project, appSet.Spec.GoTemplateOptions) 101 case len(appSetGenerator.Git.Files) != 0: 102 res, err = g.generateParamsForGitFiles(appSetGenerator, noRevisionCache, verifyCommit, appSet.Spec.GoTemplate, project, appSet.Spec.GoTemplateOptions) 103 default: 104 return nil, ErrEmptyAppSetGenerator 105 } 106 if err != nil { 107 return nil, fmt.Errorf("error generating params from git: %w", err) 108 } 109 110 return res, nil 111 } 112 113 // generateParamsForGitDirectories generates parameters for an ApplicationSet using a directory-based Git generator. 114 // It fetches all directories from the given Git repository and revision, optionally using a revision cache and verifying commits. 115 // It then filters the directories based on the generator's configuration and renders parameters for the resulting applications 116 func (g *GitGenerator) generateParamsForGitDirectories(appSetGenerator *argoprojiov1alpha1.ApplicationSetGenerator, noRevisionCache, verifyCommit, useGoTemplate bool, project string, goTemplateOptions []string) ([]map[string]any, error) { 117 allPaths, err := g.repos.GetDirectories(context.TODO(), appSetGenerator.Git.RepoURL, appSetGenerator.Git.Revision, project, noRevisionCache, verifyCommit) 118 if err != nil { 119 return nil, fmt.Errorf("error getting directories from repo: %w", err) 120 } 121 122 log.WithFields(log.Fields{ 123 "allPaths": allPaths, 124 "total": len(allPaths), 125 "repoURL": appSetGenerator.Git.RepoURL, 126 "revision": appSetGenerator.Git.Revision, 127 "pathParamPrefix": appSetGenerator.Git.PathParamPrefix, 128 }).Info("applications result from the repo service") 129 130 requestedApps := g.filterApps(appSetGenerator.Git.Directories, allPaths) 131 132 res, err := g.generateParamsFromApps(requestedApps, appSetGenerator, useGoTemplate, goTemplateOptions) 133 if err != nil { 134 return nil, fmt.Errorf("error generating params from apps: %w", err) 135 } 136 137 return res, nil 138 } 139 140 // generateParamsForGitFiles generates parameters for an ApplicationSet using a file-based Git generator. 141 // It retrieves and processes specified files from the Git repository, supporting both YAML and JSON formats, 142 // and returns a list of parameter maps extracted from the content. 143 func (g *GitGenerator) generateParamsForGitFiles(appSetGenerator *argoprojiov1alpha1.ApplicationSetGenerator, noRevisionCache, verifyCommit, useGoTemplate bool, project string, goTemplateOptions []string) ([]map[string]any, error) { 144 // fileContentMap maps absolute file paths to their byte content 145 fileContentMap := make(map[string][]byte) 146 var includePatterns []string 147 var excludePatterns []string 148 149 for _, req := range appSetGenerator.Git.Files { 150 if req.Exclude { 151 excludePatterns = append(excludePatterns, req.Path) 152 } else { 153 includePatterns = append(includePatterns, req.Path) 154 } 155 } 156 157 // Fetch all files from include patterns 158 for _, includePattern := range includePatterns { 159 retrievedFiles, err := g.repos.GetFiles( 160 context.TODO(), 161 appSetGenerator.Git.RepoURL, 162 appSetGenerator.Git.Revision, 163 project, 164 includePattern, 165 noRevisionCache, 166 verifyCommit, 167 ) 168 if err != nil { 169 return nil, err 170 } 171 for absPath, content := range retrievedFiles { 172 fileContentMap[absPath] = content 173 } 174 } 175 176 // Now remove files matching any exclude pattern 177 for _, excludePattern := range excludePatterns { 178 matchingFiles, err := g.repos.GetFiles( 179 context.TODO(), 180 appSetGenerator.Git.RepoURL, 181 appSetGenerator.Git.Revision, 182 project, 183 excludePattern, 184 noRevisionCache, 185 verifyCommit, 186 ) 187 if err != nil { 188 return nil, err 189 } 190 for absPath := range matchingFiles { 191 // if the file doesn't exist already and you try to delete it from the map 192 // the operation is a no-op. It’s safe and doesn't return an error or panic. 193 // Hence, we can simply try to delete the file from the path without checking 194 // if that file already exists in the map. 195 delete(fileContentMap, absPath) 196 } 197 } 198 199 // Get a sorted list of file paths to ensure deterministic processing order 200 var filePaths []string 201 for path := range fileContentMap { 202 filePaths = append(filePaths, path) 203 } 204 sort.Strings(filePaths) 205 206 var allParams []map[string]any 207 for _, filePath := range filePaths { 208 // A JSON / YAML file path can contain multiple sets of parameters (ie it is an array) 209 paramsFromFileArray, err := g.generateParamsFromGitFile(filePath, fileContentMap[filePath], appSetGenerator.Git.Values, useGoTemplate, goTemplateOptions, appSetGenerator.Git.PathParamPrefix) 210 if err != nil { 211 return nil, fmt.Errorf("unable to process file '%s': %w", filePath, err) 212 } 213 allParams = append(allParams, paramsFromFileArray...) 214 } 215 216 return allParams, nil 217 } 218 219 // generateParamsFromGitFile parses the content of a Git-tracked file and generates a slice of parameter maps. 220 // The file can contain a single YAML/JSON object or an array of such objects. Depending on the useGoTemplate flag, 221 // it either preserves structure for Go templating or flattens the objects for use as plain key-value parameters. 222 func (g *GitGenerator) generateParamsFromGitFile(filePath string, fileContent []byte, values map[string]string, useGoTemplate bool, goTemplateOptions []string, pathParamPrefix string) ([]map[string]any, error) { 223 objectsFound := []map[string]any{} 224 225 // First, we attempt to parse as a single object. 226 // This will also succeed for empty files. 227 singleObj := map[string]any{} 228 err := yaml.Unmarshal(fileContent, &singleObj) 229 if err == nil { 230 objectsFound = append(objectsFound, singleObj) 231 } else { 232 // If unable to parse as an object, try to parse as an array 233 err = yaml.Unmarshal(fileContent, &objectsFound) 234 if err != nil { 235 return nil, fmt.Errorf("unable to parse file: %w", err) 236 } 237 } 238 239 res := []map[string]any{} 240 241 for _, objectFound := range objectsFound { 242 params := map[string]any{} 243 244 if useGoTemplate { 245 for k, v := range objectFound { 246 params[k] = v 247 } 248 249 paramPath := map[string]any{} 250 251 paramPath["path"] = path.Dir(filePath) 252 paramPath["basename"] = path.Base(paramPath["path"].(string)) 253 paramPath["filename"] = path.Base(filePath) 254 paramPath["basenameNormalized"] = utils.SanitizeName(path.Base(paramPath["path"].(string))) 255 paramPath["filenameNormalized"] = utils.SanitizeName(path.Base(paramPath["filename"].(string))) 256 paramPath["segments"] = strings.Split(paramPath["path"].(string), "/") 257 if pathParamPrefix != "" { 258 params[pathParamPrefix] = map[string]any{"path": paramPath} 259 } else { 260 params["path"] = paramPath 261 } 262 } else { 263 flat, err := flatten.Flatten(objectFound, "", flatten.DotStyle) 264 if err != nil { 265 return nil, fmt.Errorf("error flattening object: %w", err) 266 } 267 for k, v := range flat { 268 params[k] = fmt.Sprintf("%v", v) 269 } 270 pathParamName := "path" 271 if pathParamPrefix != "" { 272 pathParamName = pathParamPrefix + "." + pathParamName 273 } 274 params[pathParamName] = path.Dir(filePath) 275 params[pathParamName+".basename"] = path.Base(params[pathParamName].(string)) 276 params[pathParamName+".filename"] = path.Base(filePath) 277 params[pathParamName+".basenameNormalized"] = utils.SanitizeName(path.Base(params[pathParamName].(string))) 278 params[pathParamName+".filenameNormalized"] = utils.SanitizeName(path.Base(params[pathParamName+".filename"].(string))) 279 for k, v := range strings.Split(params[pathParamName].(string), "/") { 280 if v != "" { 281 params[pathParamName+"["+strconv.Itoa(k)+"]"] = v 282 } 283 } 284 } 285 286 err := appendTemplatedValues(values, params, useGoTemplate, goTemplateOptions) 287 if err != nil { 288 return nil, fmt.Errorf("failed to append templated values: %w", err) 289 } 290 291 res = append(res, params) 292 } 293 294 return res, nil 295 } 296 297 // filterApps filters the list of all application paths based on inclusion and exclusion rules 298 // defined in GitDirectoryGeneratorItems. Each item can either include or exclude matching paths. 299 func (g *GitGenerator) filterApps(directories []argoprojiov1alpha1.GitDirectoryGeneratorItem, allPaths []string) []string { 300 var res []string 301 for _, appPath := range allPaths { 302 appInclude := false 303 appExclude := false 304 // Iterating over each appPath and check whether directories object has requestedPath that matches the appPath 305 for _, requestedPath := range directories { 306 match, err := path.Match(requestedPath.Path, appPath) 307 if err != nil { 308 log.WithError(err).WithField("requestedPath", requestedPath). 309 WithField("appPath", appPath).Error("error while matching appPath to requestedPath") 310 continue 311 } 312 if match && !requestedPath.Exclude { 313 appInclude = true 314 } 315 if match && requestedPath.Exclude { 316 appExclude = true 317 } 318 } 319 // Whenever there is a path with exclude: true it wont be included, even if it is included in a different path pattern 320 if appInclude && !appExclude { 321 res = append(res, appPath) 322 } 323 } 324 return res 325 } 326 327 // generateParamsFromApps generates a list of parameter maps based on the given app paths. 328 // Each app path is converted into a parameter object with path metadata (basename, segments, etc.). 329 // It supports both Go templates and flat key-value parameters. 330 func (g *GitGenerator) generateParamsFromApps(requestedApps []string, appSetGenerator *argoprojiov1alpha1.ApplicationSetGenerator, useGoTemplate bool, goTemplateOptions []string) ([]map[string]any, error) { 331 res := make([]map[string]any, len(requestedApps)) 332 for i, a := range requestedApps { 333 params := make(map[string]any, 5) 334 335 if useGoTemplate { 336 paramPath := map[string]any{} 337 paramPath["path"] = a 338 paramPath["basename"] = path.Base(a) 339 paramPath["basenameNormalized"] = utils.SanitizeName(path.Base(a)) 340 paramPath["segments"] = strings.Split(paramPath["path"].(string), "/") 341 if appSetGenerator.Git.PathParamPrefix != "" { 342 params[appSetGenerator.Git.PathParamPrefix] = map[string]any{"path": paramPath} 343 } else { 344 params["path"] = paramPath 345 } 346 } else { 347 pathParamName := "path" 348 if appSetGenerator.Git.PathParamPrefix != "" { 349 pathParamName = appSetGenerator.Git.PathParamPrefix + "." + pathParamName 350 } 351 params[pathParamName] = a 352 params[pathParamName+".basename"] = path.Base(a) 353 params[pathParamName+".basenameNormalized"] = utils.SanitizeName(path.Base(a)) 354 for k, v := range strings.Split(params[pathParamName].(string), "/") { 355 if v != "" { 356 params[pathParamName+"["+strconv.Itoa(k)+"]"] = v 357 } 358 } 359 } 360 361 err := appendTemplatedValues(appSetGenerator.Git.Values, params, useGoTemplate, goTemplateOptions) 362 if err != nil { 363 return nil, fmt.Errorf("failed to append templated values: %w", err) 364 } 365 366 res[i] = params 367 } 368 369 return res, nil 370 } 371 372 // resolveProjectName resolves a project name whether templated or not 373 func resolveProjectName(project string) string { 374 if strings.Contains(project, "{{") { 375 return "" 376 } 377 378 return project 379 }