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 }