github.com/argoproj/argo-cd/v3@v3.2.1/util/kustomize/kustomize.go (about)

     1  package kustomize
     2  
     3  import (
     4  	"errors"
     5  	"fmt"
     6  	"net/url"
     7  	"os"
     8  	"os/exec"
     9  	"path/filepath"
    10  	"regexp"
    11  	"sort"
    12  	"strings"
    13  	"sync"
    14  
    15  	"github.com/Masterminds/semver/v3"
    16  	"sigs.k8s.io/yaml"
    17  
    18  	"github.com/argoproj/argo-cd/v3/util/io"
    19  
    20  	"github.com/argoproj/gitops-engine/pkg/utils/kube"
    21  	log "github.com/sirupsen/logrus"
    22  	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
    23  
    24  	"github.com/argoproj/argo-cd/v3/pkg/apis/application/v1alpha1"
    25  	certutil "github.com/argoproj/argo-cd/v3/util/cert"
    26  	executil "github.com/argoproj/argo-cd/v3/util/exec"
    27  	"github.com/argoproj/argo-cd/v3/util/git"
    28  	"github.com/argoproj/argo-cd/v3/util/proxy"
    29  )
    30  
    31  // Image represents a Docker image in the format NAME[:TAG].
    32  type Image = string
    33  
    34  type BuildOpts struct {
    35  	KubeVersion string
    36  	APIVersions []string
    37  }
    38  
    39  // Kustomize provides wrapper functionality around the `kustomize` command.
    40  type Kustomize interface {
    41  	// Build returns a list of unstructured objects from a `kustomize build` command and extract supported parameters
    42  	Build(opts *v1alpha1.ApplicationSourceKustomize, kustomizeOptions *v1alpha1.KustomizeOptions, envVars *v1alpha1.Env, buildOpts *BuildOpts) ([]*unstructured.Unstructured, []Image, []string, error)
    43  }
    44  
    45  // NewKustomizeApp create a new wrapper to run commands on the `kustomize` command-line tool.
    46  func NewKustomizeApp(repoRoot string, path string, creds git.Creds, fromRepo string, binaryPath string, proxy string, noProxy string) Kustomize {
    47  	return &kustomize{
    48  		repoRoot:   repoRoot,
    49  		path:       path,
    50  		creds:      creds,
    51  		repo:       fromRepo,
    52  		binaryPath: binaryPath,
    53  		proxy:      proxy,
    54  		noProxy:    noProxy,
    55  	}
    56  }
    57  
    58  type kustomize struct {
    59  	// path to the Git repository root
    60  	repoRoot string
    61  	// path inside the checked out tree
    62  	path string
    63  	// creds structure
    64  	creds git.Creds
    65  	// the Git repository URL where we checked out
    66  	repo string
    67  	// optional kustomize binary path
    68  	binaryPath string
    69  	// HTTP/HTTPS proxy used to access repository
    70  	proxy string
    71  	// NoProxy specifies a list of targets where the proxy isn't used, applies only in cases where the proxy is applied
    72  	noProxy string
    73  }
    74  
    75  var KustomizationNames = []string{"kustomization.yaml", "kustomization.yml", "Kustomization"}
    76  
    77  // IsKustomization checks if the given file name matches any known kustomization file names.
    78  func IsKustomization(path string) bool {
    79  	for _, kustomization := range KustomizationNames {
    80  		if path == kustomization {
    81  			return true
    82  		}
    83  	}
    84  	return false
    85  }
    86  
    87  // findKustomizeFile looks for any known kustomization file in the path
    88  func findKustomizeFile(dir string) string {
    89  	for _, file := range KustomizationNames {
    90  		path := filepath.Join(dir, file)
    91  		if _, err := os.Stat(path); err == nil {
    92  			return file
    93  		}
    94  	}
    95  
    96  	return ""
    97  }
    98  
    99  func (k *kustomize) getBinaryPath() string {
   100  	if k.binaryPath != "" {
   101  		return k.binaryPath
   102  	}
   103  	return "kustomize"
   104  }
   105  
   106  // kustomize v3.8.5 patch release introduced a breaking change in "edit add <label/annotation>" commands:
   107  // https://github.com/kubernetes-sigs/kustomize/commit/b214fa7d5aa51d7c2ae306ec15115bf1c044fed8#diff-0328c59bcd29799e365ff0647653b886f17c8853df008cd54e7981db882c1b36
   108  func mapToEditAddArgs(val map[string]string) []string {
   109  	var args []string
   110  	if getSemverSafe(&kustomize{}).LessThan(semver.MustParse("v3.8.5")) {
   111  		arg := ""
   112  		for labelName, labelValue := range val {
   113  			if arg != "" {
   114  				arg += ","
   115  			}
   116  			arg += fmt.Sprintf("%s:%s", labelName, labelValue)
   117  		}
   118  		args = append(args, arg)
   119  	} else {
   120  		for labelName, labelValue := range val {
   121  			args = append(args, fmt.Sprintf("%s:%s", labelName, labelValue))
   122  		}
   123  	}
   124  	return args
   125  }
   126  
   127  func (k *kustomize) Build(opts *v1alpha1.ApplicationSourceKustomize, kustomizeOptions *v1alpha1.KustomizeOptions, envVars *v1alpha1.Env, buildOpts *BuildOpts) ([]*unstructured.Unstructured, []Image, []string, error) {
   128  	// commands stores all the commands that were run as part of this build.
   129  	var commands []string
   130  
   131  	env := os.Environ()
   132  	if envVars != nil {
   133  		env = append(env, envVars.Environ()...)
   134  	}
   135  
   136  	closer, environ, err := k.creds.Environ()
   137  	if err != nil {
   138  		return nil, nil, nil, err
   139  	}
   140  	defer func() { _ = closer.Close() }()
   141  
   142  	// If we were passed a HTTPS URL, make sure that we also check whether there
   143  	// is a custom CA bundle configured for connecting to the server.
   144  	if k.repo != "" && git.IsHTTPSURL(k.repo) {
   145  		parsedURL, err := url.Parse(k.repo)
   146  		if err != nil {
   147  			log.Warnf("Could not parse URL %s: %v", k.repo, err)
   148  		} else {
   149  			caPath, err := certutil.GetCertBundlePathForRepository(parsedURL.Host)
   150  			switch {
   151  			case err != nil:
   152  				// Some error while getting CA bundle
   153  				log.Warnf("Could not get CA bundle path for %s: %v", parsedURL.Host, err)
   154  			case caPath == "":
   155  				// No cert configured
   156  				log.Debugf("No caCert found for repo %s", parsedURL.Host)
   157  			default:
   158  				// Make Git use CA bundle
   159  				environ = append(environ, "GIT_SSL_CAINFO="+caPath)
   160  			}
   161  		}
   162  	}
   163  
   164  	env = append(env, environ...)
   165  
   166  	if opts != nil {
   167  		if opts.NamePrefix != "" {
   168  			cmd := exec.Command(k.getBinaryPath(), "edit", "set", "nameprefix", "--", opts.NamePrefix)
   169  			cmd.Dir = k.path
   170  			commands = append(commands, executil.GetCommandArgsToLog(cmd))
   171  			_, err := executil.Run(cmd)
   172  			if err != nil {
   173  				return nil, nil, nil, err
   174  			}
   175  		}
   176  		if opts.NameSuffix != "" {
   177  			cmd := exec.Command(k.getBinaryPath(), "edit", "set", "namesuffix", "--", opts.NameSuffix)
   178  			cmd.Dir = k.path
   179  			commands = append(commands, executil.GetCommandArgsToLog(cmd))
   180  			_, err := executil.Run(cmd)
   181  			if err != nil {
   182  				return nil, nil, nil, err
   183  			}
   184  		}
   185  		if len(opts.Images) > 0 {
   186  			// set image postgres=eu.gcr.io/my-project/postgres:latest my-app=my-registry/my-app@sha256:24a0c4b4a4c0eb97a1aabb8e29f18e917d05abfe1b7a7c07857230879ce7d3d3
   187  			// set image node:8.15.0 mysql=mariadb alpine@sha256:24a0c4b4a4c0eb97a1aabb8e29f18e917d05abfe1b7a7c07857230879ce7d3d3
   188  			args := []string{"edit", "set", "image"}
   189  			for _, image := range opts.Images {
   190  				// this allows using ${ARGOCD_APP_REVISION}
   191  				envSubstitutedImage := envVars.Envsubst(string(image))
   192  				args = append(args, envSubstitutedImage)
   193  			}
   194  			cmd := exec.Command(k.getBinaryPath(), args...)
   195  			cmd.Dir = k.path
   196  			commands = append(commands, executil.GetCommandArgsToLog(cmd))
   197  			_, err := executil.Run(cmd)
   198  			if err != nil {
   199  				return nil, nil, nil, err
   200  			}
   201  		}
   202  
   203  		if len(opts.Replicas) > 0 {
   204  			// set replicas my-development=2 my-statefulset=4
   205  			args := []string{"edit", "set", "replicas"}
   206  			for _, replica := range opts.Replicas {
   207  				count, err := replica.GetIntCount()
   208  				if err != nil {
   209  					return nil, nil, nil, err
   210  				}
   211  				arg := fmt.Sprintf("%s=%d", replica.Name, count)
   212  				args = append(args, arg)
   213  			}
   214  
   215  			cmd := exec.Command(k.getBinaryPath(), args...)
   216  			cmd.Dir = k.path
   217  			commands = append(commands, executil.GetCommandArgsToLog(cmd))
   218  			_, err := executil.Run(cmd)
   219  			if err != nil {
   220  				return nil, nil, nil, err
   221  			}
   222  		}
   223  
   224  		if len(opts.CommonLabels) > 0 {
   225  			//  edit add label foo:bar
   226  			args := []string{"edit", "add", "label"}
   227  			if opts.ForceCommonLabels {
   228  				args = append(args, "--force")
   229  			}
   230  			if opts.LabelWithoutSelector {
   231  				args = append(args, "--without-selector")
   232  			}
   233  			if opts.LabelIncludeTemplates {
   234  				args = append(args, "--include-templates")
   235  			}
   236  			commonLabels := map[string]string{}
   237  			for name, value := range opts.CommonLabels {
   238  				commonLabels[name] = envVars.Envsubst(value)
   239  			}
   240  			cmd := exec.Command(k.getBinaryPath(), append(args, mapToEditAddArgs(commonLabels)...)...)
   241  			cmd.Dir = k.path
   242  			commands = append(commands, executil.GetCommandArgsToLog(cmd))
   243  			_, err := executil.Run(cmd)
   244  			if err != nil {
   245  				return nil, nil, nil, err
   246  			}
   247  		}
   248  
   249  		if len(opts.CommonAnnotations) > 0 {
   250  			//  edit add annotation foo:bar
   251  			args := []string{"edit", "add", "annotation"}
   252  			if opts.ForceCommonAnnotations {
   253  				args = append(args, "--force")
   254  			}
   255  			var commonAnnotations map[string]string
   256  			if opts.CommonAnnotationsEnvsubst {
   257  				commonAnnotations = map[string]string{}
   258  				for name, value := range opts.CommonAnnotations {
   259  					commonAnnotations[name] = envVars.Envsubst(value)
   260  				}
   261  			} else {
   262  				commonAnnotations = opts.CommonAnnotations
   263  			}
   264  			cmd := exec.Command(k.getBinaryPath(), append(args, mapToEditAddArgs(commonAnnotations)...)...)
   265  			cmd.Dir = k.path
   266  			commands = append(commands, executil.GetCommandArgsToLog(cmd))
   267  			_, err := executil.Run(cmd)
   268  			if err != nil {
   269  				return nil, nil, nil, err
   270  			}
   271  		}
   272  
   273  		if opts.Namespace != "" {
   274  			cmd := exec.Command(k.getBinaryPath(), "edit", "set", "namespace", "--", opts.Namespace)
   275  			cmd.Dir = k.path
   276  			commands = append(commands, executil.GetCommandArgsToLog(cmd))
   277  			_, err := executil.Run(cmd)
   278  			if err != nil {
   279  				return nil, nil, nil, err
   280  			}
   281  		}
   282  
   283  		if len(opts.Patches) > 0 {
   284  			kustFile := findKustomizeFile(k.path)
   285  			// If the kustomization file is not found, return early.
   286  			// There is no point reading the kustomization path if it doesn't exist.
   287  			if kustFile == "" {
   288  				return nil, nil, nil, errors.New("kustomization file not found in the path")
   289  			}
   290  			kustomizationPath := filepath.Join(k.path, kustFile)
   291  			b, err := os.ReadFile(kustomizationPath)
   292  			if err != nil {
   293  				return nil, nil, nil, fmt.Errorf("failed to load kustomization.yaml: %w", err)
   294  			}
   295  			var kustomization any
   296  			err = yaml.Unmarshal(b, &kustomization)
   297  			if err != nil {
   298  				return nil, nil, nil, fmt.Errorf("failed to unmarshal kustomization.yaml: %w", err)
   299  			}
   300  			kMap, ok := kustomization.(map[string]any)
   301  			if !ok {
   302  				return nil, nil, nil, fmt.Errorf("expected kustomization.yaml to be type map[string]any, but got %T", kMap)
   303  			}
   304  			patches, ok := kMap["patches"]
   305  			if ok {
   306  				// The kustomization.yaml already had a patches field, so we need to append to it.
   307  				patchesList, ok := patches.([]any)
   308  				if !ok {
   309  					return nil, nil, nil, fmt.Errorf("expected 'patches' field in kustomization.yaml to be []any, but got %T", patches)
   310  				}
   311  				// Since the patches from the Application manifest are typed, we need to convert them to a type which
   312  				// can be appended to the existing list.
   313  				untypedPatches := make([]any, len(opts.Patches))
   314  				for i := range opts.Patches {
   315  					untypedPatches[i] = opts.Patches[i]
   316  				}
   317  				patchesList = append(patchesList, untypedPatches...)
   318  				// Update the kustomization.yaml with the appended patches list.
   319  				kMap["patches"] = patchesList
   320  			} else {
   321  				kMap["patches"] = opts.Patches
   322  			}
   323  			updatedKustomization, err := yaml.Marshal(kMap)
   324  			if err != nil {
   325  				return nil, nil, nil, fmt.Errorf("failed to marshal kustomization.yaml after adding patches: %w", err)
   326  			}
   327  			kustomizationFileInfo, err := os.Stat(kustomizationPath)
   328  			if err != nil {
   329  				return nil, nil, nil, fmt.Errorf("failed to stat kustomization.yaml: %w", err)
   330  			}
   331  			err = os.WriteFile(kustomizationPath, updatedKustomization, kustomizationFileInfo.Mode())
   332  			if err != nil {
   333  				return nil, nil, nil, fmt.Errorf("failed to write kustomization.yaml with updated 'patches' field: %w", err)
   334  			}
   335  			commands = append(commands, "# kustomization.yaml updated with patches. There is no `kustomize edit` command for adding patches. In order to generate the manifests in your local environment, you will need to copy the patches into kustomization.yaml manually.")
   336  		}
   337  
   338  		if len(opts.Components) > 0 {
   339  			// components only supported in kustomize >= v3.7.0
   340  			// https://github.com/kubernetes-sigs/kustomize/blob/master/examples/components.md
   341  			if getSemverSafe(k).LessThan(semver.MustParse("v3.7.0")) {
   342  				return nil, nil, nil, errors.New("kustomize components require kustomize v3.7.0 and above")
   343  			}
   344  
   345  			// add components
   346  			foundComponents := opts.Components
   347  			if opts.IgnoreMissingComponents {
   348  				foundComponents = make([]string, 0)
   349  				root, err := os.OpenRoot(k.repoRoot)
   350  				defer io.Close(root)
   351  				if err != nil {
   352  					return nil, nil, nil, fmt.Errorf("failed to open the repo folder: %w", err)
   353  				}
   354  
   355  				for _, c := range opts.Components {
   356  					resolvedPath, err := filepath.Rel(k.repoRoot, filepath.Join(k.path, c))
   357  					if err != nil {
   358  						return nil, nil, nil, fmt.Errorf("kustomize components path failed: %w", err)
   359  					}
   360  					_, err = root.Stat(resolvedPath)
   361  					if err != nil {
   362  						log.Debugf("%s component directory does not exist", resolvedPath)
   363  						continue
   364  					}
   365  					foundComponents = append(foundComponents, c)
   366  				}
   367  			}
   368  
   369  			if len(foundComponents) > 0 {
   370  				args := []string{"edit", "add", "component"}
   371  				args = append(args, foundComponents...)
   372  				cmd := exec.Command(k.getBinaryPath(), args...)
   373  				cmd.Dir = k.path
   374  				cmd.Env = env
   375  				commands = append(commands, executil.GetCommandArgsToLog(cmd))
   376  				_, err := executil.Run(cmd)
   377  				if err != nil {
   378  					return nil, nil, nil, err
   379  				}
   380  			}
   381  		}
   382  	}
   383  
   384  	var cmd *exec.Cmd
   385  	if kustomizeOptions != nil && kustomizeOptions.BuildOptions != "" {
   386  		params := parseKustomizeBuildOptions(k, kustomizeOptions.BuildOptions, buildOpts)
   387  		cmd = exec.Command(k.getBinaryPath(), params...)
   388  	} else {
   389  		cmd = exec.Command(k.getBinaryPath(), "build", k.path)
   390  	}
   391  	cmd.Env = env
   392  	cmd.Env = proxy.UpsertEnv(cmd, k.proxy, k.noProxy)
   393  	cmd.Dir = k.repoRoot
   394  	commands = append(commands, executil.GetCommandArgsToLog(cmd))
   395  	out, err := executil.Run(cmd)
   396  	if err != nil {
   397  		return nil, nil, nil, err
   398  	}
   399  
   400  	objs, err := kube.SplitYAML([]byte(out))
   401  	if err != nil {
   402  		return nil, nil, nil, err
   403  	}
   404  
   405  	redactedCommands := make([]string, len(commands))
   406  	for i, c := range commands {
   407  		redactedCommands[i] = strings.ReplaceAll(c, k.repoRoot, ".")
   408  	}
   409  
   410  	return objs, getImageParameters(objs), redactedCommands, nil
   411  }
   412  
   413  func parseKustomizeBuildOptions(k *kustomize, buildOptions string, buildOpts *BuildOpts) []string {
   414  	buildOptsParams := append([]string{"build", k.path}, strings.Fields(buildOptions)...)
   415  
   416  	if buildOpts != nil && !getSemverSafe(k).LessThan(semver.MustParse("v5.3.0")) && isHelmEnabled(buildOptions) {
   417  		if buildOpts.KubeVersion != "" {
   418  			buildOptsParams = append(buildOptsParams, "--helm-kube-version", buildOpts.KubeVersion)
   419  		}
   420  		for _, v := range buildOpts.APIVersions {
   421  			buildOptsParams = append(buildOptsParams, "--helm-api-versions", v)
   422  		}
   423  	}
   424  
   425  	return buildOptsParams
   426  }
   427  
   428  func isHelmEnabled(buildOptions string) bool {
   429  	return strings.Contains(buildOptions, "--enable-helm")
   430  }
   431  
   432  // semver/v3 doesn't export the regexp anymore, so shamelessly copied it over to
   433  // here.
   434  // https://github.com/Masterminds/semver/blob/49c09bfed6adcffa16482ddc5e5588cffff9883a/version.go#L42
   435  const semVerRegex string = `v?([0-9]+)(\.[0-9]+)?(\.[0-9]+)?` +
   436  	`(-([0-9A-Za-z\-]+(\.[0-9A-Za-z\-]+)*))?` +
   437  	`(\+([0-9A-Za-z\-]+(\.[0-9A-Za-z\-]+)*))?`
   438  
   439  var (
   440  	unknownVersion = semver.MustParse("v99.99.99")
   441  	semverRegex    = regexp.MustCompile(semVerRegex)
   442  	semVer         *semver.Version
   443  	semVerLock     sync.Mutex
   444  )
   445  
   446  // getSemver returns parsed kustomize version
   447  func getSemver(k *kustomize) (*semver.Version, error) {
   448  	verStr, err := versionWithBinaryPath(k)
   449  	if err != nil {
   450  		return nil, err
   451  	}
   452  
   453  	semverMatches := semverRegex.FindStringSubmatch(verStr)
   454  	if len(semverMatches) == 0 {
   455  		return nil, fmt.Errorf("expected string that includes semver formatted version but got: '%s'", verStr)
   456  	}
   457  
   458  	return semver.NewVersion(semverMatches[0])
   459  }
   460  
   461  // getSemverSafe returns parsed kustomize version;
   462  // if version cannot be parsed assumes that "kustomize version" output format changed again
   463  // and fallback to latest ( v99.99.99 )
   464  func getSemverSafe(k *kustomize) *semver.Version {
   465  	if semVer == nil {
   466  		semVerLock.Lock()
   467  		defer semVerLock.Unlock()
   468  
   469  		if ver, err := getSemver(k); err != nil {
   470  			semVer = unknownVersion
   471  			log.Warnf("Failed to parse kustomize version: %v", err)
   472  		} else {
   473  			semVer = ver
   474  		}
   475  	}
   476  	return semVer
   477  }
   478  
   479  func Version() (string, error) {
   480  	return versionWithBinaryPath(&kustomize{})
   481  }
   482  
   483  func versionWithBinaryPath(k *kustomize) (string, error) {
   484  	executable := k.getBinaryPath()
   485  	cmd := exec.Command(executable, "version", "--short")
   486  	// example version output:
   487  	// short: "{kustomize/v3.8.1  2020-07-16T00:58:46Z  }"
   488  	version, err := executil.Run(cmd)
   489  	if err != nil {
   490  		return "", fmt.Errorf("could not get kustomize version: %w", err)
   491  	}
   492  	version = strings.TrimSpace(version)
   493  	// trim the curly braces
   494  	version = strings.TrimPrefix(version, "{")
   495  	version = strings.TrimSuffix(version, "}")
   496  	version = strings.TrimSpace(version)
   497  
   498  	// remove double space in middle
   499  	version = strings.ReplaceAll(version, "  ", " ")
   500  
   501  	// remove extra 'kustomize/' before version
   502  	version = strings.TrimPrefix(version, "kustomize/")
   503  	return version, nil
   504  }
   505  
   506  func getImageParameters(objs []*unstructured.Unstructured) []Image {
   507  	var images []Image
   508  	for _, obj := range objs {
   509  		images = append(images, getImages(obj.Object)...)
   510  	}
   511  	sort.Strings(images)
   512  	return images
   513  }
   514  
   515  func getImages(object map[string]any) []Image {
   516  	var images []Image
   517  	for k, v := range object {
   518  		switch v := v.(type) {
   519  		case []any:
   520  			if k == "containers" || k == "initContainers" {
   521  				for _, obj := range v {
   522  					if mapObj, isMapObj := obj.(map[string]any); isMapObj {
   523  						if image, hasImage := mapObj["image"]; hasImage {
   524  							images = append(images, fmt.Sprintf("%s", image))
   525  						}
   526  					}
   527  				}
   528  			} else {
   529  				for i := range v {
   530  					if mapObj, isMapObj := v[i].(map[string]any); isMapObj {
   531  						images = append(images, getImages(mapObj)...)
   532  					}
   533  				}
   534  			}
   535  		case map[string]any:
   536  			images = append(images, getImages(v)...)
   537  		}
   538  	}
   539  	return images
   540  }