github.com/stefanmcshane/helm@v0.0.0-20221213002717-88a4a2c6e77d/pkg/lint/rules/template.go (about)

     1  /*
     2  Copyright The Helm Authors.
     3  
     4  Licensed under the Apache License, Version 2.0 (the "License");
     5  you may not use this file except in compliance with the License.
     6  You may obtain a copy of the License at
     7  
     8      http://www.apache.org/licenses/LICENSE-2.0
     9  
    10  Unless required by applicable law or agreed to in writing, software
    11  distributed under the License is distributed on an "AS IS" BASIS,
    12  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    13  See the License for the specific language governing permissions and
    14  limitations under the License.
    15  */
    16  
    17  package rules
    18  
    19  import (
    20  	"bufio"
    21  	"bytes"
    22  	"fmt"
    23  	"io"
    24  	"os"
    25  	"path"
    26  	"path/filepath"
    27  	"regexp"
    28  	"strings"
    29  
    30  	"github.com/pkg/errors"
    31  	"k8s.io/apimachinery/pkg/api/validation"
    32  	apipath "k8s.io/apimachinery/pkg/api/validation/path"
    33  	"k8s.io/apimachinery/pkg/util/validation/field"
    34  	"k8s.io/apimachinery/pkg/util/yaml"
    35  
    36  	"github.com/stefanmcshane/helm/pkg/chart/loader"
    37  	"github.com/stefanmcshane/helm/pkg/chartutil"
    38  	"github.com/stefanmcshane/helm/pkg/engine"
    39  	"github.com/stefanmcshane/helm/pkg/lint/support"
    40  )
    41  
    42  var (
    43  	crdHookSearch     = regexp.MustCompile(`"?helm\.sh/hook"?:\s+crd-install`)
    44  	releaseTimeSearch = regexp.MustCompile(`\.Release\.Time`)
    45  )
    46  
    47  // Templates lints the templates in the Linter.
    48  func Templates(linter *support.Linter, values map[string]interface{}, namespace string, strict bool) {
    49  	fpath := "templates/"
    50  	templatesPath := filepath.Join(linter.ChartDir, fpath)
    51  
    52  	templatesDirExist := linter.RunLinterRule(support.WarningSev, fpath, validateTemplatesDir(templatesPath))
    53  
    54  	// Templates directory is optional for now
    55  	if !templatesDirExist {
    56  		return
    57  	}
    58  
    59  	// Load chart and parse templates
    60  	chart, err := loader.Load(linter.ChartDir)
    61  
    62  	chartLoaded := linter.RunLinterRule(support.ErrorSev, fpath, err)
    63  
    64  	if !chartLoaded {
    65  		return
    66  	}
    67  
    68  	options := chartutil.ReleaseOptions{
    69  		Name:      "test-release",
    70  		Namespace: namespace,
    71  	}
    72  
    73  	// lint ignores import-values
    74  	// See https://github.com/helm/helm/issues/9658
    75  	if err := chartutil.ProcessDependencies(chart, values); err != nil {
    76  		return
    77  	}
    78  
    79  	cvals, err := chartutil.CoalesceValues(chart, values)
    80  	if err != nil {
    81  		return
    82  	}
    83  	valuesToRender, err := chartutil.ToRenderValues(chart, cvals, options, nil)
    84  	if err != nil {
    85  		linter.RunLinterRule(support.ErrorSev, fpath, err)
    86  		return
    87  	}
    88  	var e engine.Engine
    89  	e.LintMode = true
    90  	renderedContentMap, err := e.Render(chart, valuesToRender)
    91  
    92  	renderOk := linter.RunLinterRule(support.ErrorSev, fpath, err)
    93  
    94  	if !renderOk {
    95  		return
    96  	}
    97  
    98  	/* Iterate over all the templates to check:
    99  	- It is a .yaml file
   100  	- All the values in the template file is defined
   101  	- {{}} include | quote
   102  	- Generated content is a valid Yaml file
   103  	- Metadata.Namespace is not set
   104  	*/
   105  	for _, template := range chart.Templates {
   106  		fileName, data := template.Name, template.Data
   107  		fpath = fileName
   108  
   109  		linter.RunLinterRule(support.ErrorSev, fpath, validateAllowedExtension(fileName))
   110  		// These are v3 specific checks to make sure and warn people if their
   111  		// chart is not compatible with v3
   112  		linter.RunLinterRule(support.WarningSev, fpath, validateNoCRDHooks(data))
   113  		linter.RunLinterRule(support.ErrorSev, fpath, validateNoReleaseTime(data))
   114  
   115  		// We only apply the following lint rules to yaml files
   116  		if filepath.Ext(fileName) != ".yaml" || filepath.Ext(fileName) == ".yml" {
   117  			continue
   118  		}
   119  
   120  		// NOTE: disabled for now, Refs https://github.com/helm/helm/issues/1463
   121  		// Check that all the templates have a matching value
   122  		// linter.RunLinterRule(support.WarningSev, fpath, validateNoMissingValues(templatesPath, valuesToRender, preExecutedTemplate))
   123  
   124  		// NOTE: disabled for now, Refs https://github.com/helm/helm/issues/1037
   125  		// linter.RunLinterRule(support.WarningSev, fpath, validateQuotes(string(preExecutedTemplate)))
   126  
   127  		renderedContent := renderedContentMap[path.Join(chart.Name(), fileName)]
   128  		if strings.TrimSpace(renderedContent) != "" {
   129  			linter.RunLinterRule(support.WarningSev, fpath, validateTopIndentLevel(renderedContent))
   130  
   131  			decoder := yaml.NewYAMLOrJSONDecoder(strings.NewReader(renderedContent), 4096)
   132  
   133  			// Lint all resources if the file contains multiple documents separated by ---
   134  			for {
   135  				// Even though K8sYamlStruct only defines a few fields, an error in any other
   136  				// key will be raised as well
   137  				var yamlStruct *K8sYamlStruct
   138  
   139  				err := decoder.Decode(&yamlStruct)
   140  				if err == io.EOF {
   141  					break
   142  				}
   143  
   144  				// If YAML linting fails, we sill progress. So we don't capture the returned state
   145  				// on this linter run.
   146  				linter.RunLinterRule(support.ErrorSev, fpath, validateYamlContent(err))
   147  
   148  				if yamlStruct != nil {
   149  					// NOTE: set to warnings to allow users to support out-of-date kubernetes
   150  					// Refs https://github.com/helm/helm/issues/8596
   151  					linter.RunLinterRule(support.WarningSev, fpath, validateMetadataName(yamlStruct))
   152  					linter.RunLinterRule(support.WarningSev, fpath, validateNoDeprecations(yamlStruct))
   153  
   154  					linter.RunLinterRule(support.ErrorSev, fpath, validateMatchSelector(yamlStruct, renderedContent))
   155  					linter.RunLinterRule(support.ErrorSev, fpath, validateListAnnotations(yamlStruct, renderedContent))
   156  				}
   157  			}
   158  		}
   159  	}
   160  }
   161  
   162  // validateTopIndentLevel checks that the content does not start with an indent level > 0.
   163  //
   164  // This error can occur when a template accidentally inserts space. It can cause
   165  // unpredictable errors depending on whether the text is normalized before being passed
   166  // into the YAML parser. So we trap it here.
   167  //
   168  // See https://github.com/helm/helm/issues/8467
   169  func validateTopIndentLevel(content string) error {
   170  	// Read lines until we get to a non-empty one
   171  	scanner := bufio.NewScanner(bytes.NewBufferString(content))
   172  	for scanner.Scan() {
   173  		line := scanner.Text()
   174  		// If line is empty, skip
   175  		if strings.TrimSpace(line) == "" {
   176  			continue
   177  		}
   178  		// If it starts with one or more spaces, this is an error
   179  		if strings.HasPrefix(line, " ") || strings.HasPrefix(line, "\t") {
   180  			return fmt.Errorf("document starts with an illegal indent: %q, which may cause parsing problems", line)
   181  		}
   182  		// Any other condition passes.
   183  		return nil
   184  	}
   185  	return scanner.Err()
   186  }
   187  
   188  // Validation functions
   189  func validateTemplatesDir(templatesPath string) error {
   190  	if fi, err := os.Stat(templatesPath); err != nil {
   191  		return errors.New("directory not found")
   192  	} else if !fi.IsDir() {
   193  		return errors.New("not a directory")
   194  	}
   195  	return nil
   196  }
   197  
   198  func validateAllowedExtension(fileName string) error {
   199  	ext := filepath.Ext(fileName)
   200  	validExtensions := []string{".yaml", ".yml", ".tpl", ".txt"}
   201  
   202  	for _, b := range validExtensions {
   203  		if b == ext {
   204  			return nil
   205  		}
   206  	}
   207  
   208  	return errors.Errorf("file extension '%s' not valid. Valid extensions are .yaml, .yml, .tpl, or .txt", ext)
   209  }
   210  
   211  func validateYamlContent(err error) error {
   212  	return errors.Wrap(err, "unable to parse YAML")
   213  }
   214  
   215  // validateMetadataName uses the correct validation function for the object
   216  // Kind, or if not set, defaults to the standard definition of a subdomain in
   217  // DNS (RFC 1123), used by most resources.
   218  func validateMetadataName(obj *K8sYamlStruct) error {
   219  	fn := validateMetadataNameFunc(obj)
   220  	allErrs := field.ErrorList{}
   221  	for _, msg := range fn(obj.Metadata.Name, false) {
   222  		allErrs = append(allErrs, field.Invalid(field.NewPath("metadata").Child("name"), obj.Metadata.Name, msg))
   223  	}
   224  	if len(allErrs) > 0 {
   225  		return errors.Wrapf(allErrs.ToAggregate(), "object name does not conform to Kubernetes naming requirements: %q", obj.Metadata.Name)
   226  	}
   227  	return nil
   228  }
   229  
   230  // validateMetadataNameFunc will return a name validation function for the
   231  // object kind, if defined below.
   232  //
   233  // Rules should match those set in the various api validations:
   234  // https://github.com/kubernetes/kubernetes/blob/v1.20.0/pkg/apis/core/validation/validation.go#L205-L274
   235  // https://github.com/kubernetes/kubernetes/blob/v1.20.0/pkg/apis/apps/validation/validation.go#L39
   236  // ...
   237  //
   238  // Implementing here to avoid importing k/k.
   239  //
   240  // If no mapping is defined, returns NameIsDNSSubdomain.  This is used by object
   241  // kinds that don't have special requirements, so is the most likely to work if
   242  // new kinds are added.
   243  func validateMetadataNameFunc(obj *K8sYamlStruct) validation.ValidateNameFunc {
   244  	switch strings.ToLower(obj.Kind) {
   245  	case "pod", "node", "secret", "endpoints", "resourcequota", // core
   246  		"controllerrevision", "daemonset", "deployment", "replicaset", "statefulset", // apps
   247  		"autoscaler",     // autoscaler
   248  		"cronjob", "job", // batch
   249  		"lease",                    // coordination
   250  		"endpointslice",            // discovery
   251  		"networkpolicy", "ingress", // networking
   252  		"podsecuritypolicy",                           // policy
   253  		"priorityclass",                               // scheduling
   254  		"podpreset",                                   // settings
   255  		"storageclass", "volumeattachment", "csinode": // storage
   256  		return validation.NameIsDNSSubdomain
   257  	case "service":
   258  		return validation.NameIsDNS1035Label
   259  	case "namespace":
   260  		return validation.ValidateNamespaceName
   261  	case "serviceaccount":
   262  		return validation.ValidateServiceAccountName
   263  	case "certificatesigningrequest":
   264  		// No validation.
   265  		// https://github.com/kubernetes/kubernetes/blob/v1.20.0/pkg/apis/certificates/validation/validation.go#L137-L140
   266  		return func(name string, prefix bool) []string { return nil }
   267  	case "role", "clusterrole", "rolebinding", "clusterrolebinding":
   268  		// https://github.com/kubernetes/kubernetes/blob/v1.20.0/pkg/apis/rbac/validation/validation.go#L32-L34
   269  		return func(name string, prefix bool) []string {
   270  			return apipath.IsValidPathSegmentName(name)
   271  		}
   272  	default:
   273  		return validation.NameIsDNSSubdomain
   274  	}
   275  }
   276  
   277  func validateNoCRDHooks(manifest []byte) error {
   278  	if crdHookSearch.Match(manifest) {
   279  		return errors.New("manifest is a crd-install hook. This hook is no longer supported in v3 and all CRDs should also exist the crds/ directory at the top level of the chart")
   280  	}
   281  	return nil
   282  }
   283  
   284  func validateNoReleaseTime(manifest []byte) error {
   285  	if releaseTimeSearch.Match(manifest) {
   286  		return errors.New(".Release.Time has been removed in v3, please replace with the `now` function in your templates")
   287  	}
   288  	return nil
   289  }
   290  
   291  // validateMatchSelector ensures that template specs have a selector declared.
   292  // See https://github.com/helm/helm/issues/1990
   293  func validateMatchSelector(yamlStruct *K8sYamlStruct, manifest string) error {
   294  	switch yamlStruct.Kind {
   295  	case "Deployment", "ReplicaSet", "DaemonSet", "StatefulSet":
   296  		// verify that matchLabels or matchExpressions is present
   297  		if !(strings.Contains(manifest, "matchLabels") || strings.Contains(manifest, "matchExpressions")) {
   298  			return fmt.Errorf("a %s must contain matchLabels or matchExpressions, and %q does not", yamlStruct.Kind, yamlStruct.Metadata.Name)
   299  		}
   300  	}
   301  	return nil
   302  }
   303  func validateListAnnotations(yamlStruct *K8sYamlStruct, manifest string) error {
   304  	if yamlStruct.Kind == "List" {
   305  		m := struct {
   306  			Items []struct {
   307  				Metadata struct {
   308  					Annotations map[string]string
   309  				}
   310  			}
   311  		}{}
   312  
   313  		if err := yaml.Unmarshal([]byte(manifest), &m); err != nil {
   314  			return validateYamlContent(err)
   315  		}
   316  
   317  		for _, i := range m.Items {
   318  			if _, ok := i.Metadata.Annotations["helm.sh/resource-policy"]; ok {
   319  				return errors.New("Annotation 'helm.sh/resource-policy' within List objects are ignored")
   320  			}
   321  		}
   322  	}
   323  	return nil
   324  }
   325  
   326  // K8sYamlStruct stubs a Kubernetes YAML file.
   327  //
   328  // DEPRECATED: In Helm 4, this will be made a private type, as it is for use only within
   329  // the rules package.
   330  type K8sYamlStruct struct {
   331  	APIVersion string `json:"apiVersion"`
   332  	Kind       string
   333  	Metadata   k8sYamlMetadata
   334  }
   335  
   336  type k8sYamlMetadata struct {
   337  	Namespace string
   338  	Name      string
   339  }