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  }