github.com/wmuizelaar/kpt@v0.0.0-20221018115725-bd564717b2ed/pkg/api/kptfile/v1/validation.go (about) 1 // Copyright 2021 Google LLC 2 // 3 // Licensed under the Apache License, Version 2.0 (the "License"); 4 // you may not use this file except in compliance with the License. 5 // You may obtain a copy of the License at 6 // 7 // http://www.apache.org/licenses/LICENSE-2.0 8 // 9 // Unless required by applicable law or agreed to in writing, software 10 // distributed under the License is distributed on an "AS IS" BASIS, 11 // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 // See the License for the specific language governing permissions and 13 // limitations under the License. 14 15 package v1 16 17 import ( 18 "fmt" 19 "path/filepath" 20 "regexp" 21 "strings" 22 23 "github.com/GoogleContainerTools/kpt/internal/types" 24 "sigs.k8s.io/kustomize/api/konfig" 25 kustomizetypes "sigs.k8s.io/kustomize/api/types" 26 "sigs.k8s.io/kustomize/kyaml/filesys" 27 "sigs.k8s.io/kustomize/kyaml/kio" 28 "sigs.k8s.io/kustomize/kyaml/kio/kioutil" 29 "sigs.k8s.io/kustomize/kyaml/yaml" 30 ) 31 32 const ( 33 // constants related to kustomize 34 kustomizationAPIGroup = "kustomize.config.k8s.io" 35 ) 36 37 func (kf *KptFile) Validate(fsys filesys.FileSystem, pkgPath types.UniquePath) error { 38 if err := kf.Pipeline.validate(fsys, pkgPath); err != nil { 39 return fmt.Errorf("invalid pipeline: %w", err) 40 } 41 // TODO: validate other fields 42 return nil 43 } 44 45 // validate will validate all fields in the Pipeline 46 // 'mutators' and 'validators' share same schema and 47 // they are valid if all functions in them are ALL valid. 48 func (p *Pipeline) validate(fsys filesys.FileSystem, pkgPath types.UniquePath) error { 49 if p == nil { 50 return nil 51 } 52 for i := range p.Mutators { 53 f := p.Mutators[i] 54 err := f.validate(fsys, "mutators", i, pkgPath) 55 if err != nil { 56 return fmt.Errorf("function %q: %w", f.Image, err) 57 } 58 } 59 for i := range p.Validators { 60 f := p.Validators[i] 61 err := f.validate(fsys, "validators", i, pkgPath) 62 if err != nil { 63 return fmt.Errorf("function %q: %w", f.Image, err) 64 } 65 } 66 return nil 67 } 68 69 func (f *Function) validate(fsys filesys.FileSystem, fnType string, idx int, pkgPath types.UniquePath) error { 70 if f.Image == "" && f.Exec == "" { 71 return &ValidateError{ 72 Field: fmt.Sprintf("pipeline.%s[%d]", fnType, idx), 73 Reason: "must specify a functon (`image` or `exec`) to execute", 74 } 75 } 76 if f.Image != "" && f.Exec != "" { 77 return &ValidateError{ 78 Field: fmt.Sprintf("pipeline.%s[%d]", fnType, idx), 79 Reason: "must not specify both `image` and `exec` at the same time", 80 } 81 } 82 if f.Image != "" { 83 err := ValidateFunctionImageURL(f.Image) 84 if err != nil { 85 return &ValidateError{ 86 Field: fmt.Sprintf("pipeline.%s[%d].image", fnType, idx), 87 Value: f.Image, 88 Reason: err.Error(), 89 } 90 } 91 } 92 // TODO(droot): validate the exec 93 94 if len(f.ConfigMap) != 0 && f.ConfigPath != "" { 95 return &ValidateError{ 96 Field: fmt.Sprintf("pipeline.%s[%d]", fnType, idx), 97 Reason: "functionConfig must not specify both `configMap` and `configPath` at the same time", 98 } 99 } 100 101 if f.ConfigPath != "" { 102 if err := validateFnConfigPathSyntax(f.ConfigPath); err != nil { 103 return &ValidateError{ 104 Field: fmt.Sprintf("pipeline.%s[%d].configPath", fnType, idx), 105 Value: f.ConfigPath, 106 Reason: err.Error(), 107 } 108 } 109 if _, err := GetValidatedFnConfigFromPath(fsys, pkgPath, f.ConfigPath); err != nil { 110 return &ValidateError{ 111 Field: fmt.Sprintf("pipeline.%s[%d].configPath", fnType, idx), 112 Value: f.ConfigPath, 113 Reason: err.Error(), 114 } 115 } 116 } 117 return nil 118 } 119 120 // ValidateFunctionImageURL validates the function name. 121 // According to Docker implementation 122 // https://github.com/docker/distribution/blob/master/reference/reference.go. A valid 123 // name definition is: 124 // 125 // name := [domain '/'] path-component ['/' path-component]* 126 // domain := domain-component ['.' domain-component]* [':' port-number] 127 // domain-component := /([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9])/ 128 // port-number := /[0-9]+/ 129 // path-component := alpha-numeric [separator alpha-numeric]* 130 // alpha-numeric := /[a-z0-9]+/ 131 // separator := /[_.]|__|[-]*/ 132 func ValidateFunctionImageURL(name string) error { 133 pathComponentRegexp := `(?:[a-z0-9](?:(?:[_.]|__|[-]*)[a-z0-9]+)*)` 134 domainComponentRegexp := `(?:[a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9])` 135 domainRegexp := fmt.Sprintf(`%s(?:\.%s)*(?:\:[0-9]+)?`, domainComponentRegexp, domainComponentRegexp) 136 nameRegexp := fmt.Sprintf(`(?:%s\/)?%s(?:\/%s)*`, domainRegexp, 137 pathComponentRegexp, pathComponentRegexp) 138 tagRegexp := `(?:[\w][\w.-]{0,127})` 139 shaRegexp := `(sha256:[a-zA-Z0-9]{64})` 140 versionRegexp := fmt.Sprintf(`(%s|%s)`, tagRegexp, shaRegexp) 141 r := fmt.Sprintf(`^(?:%s(?:(\:|@)%s)?)$`, nameRegexp, versionRegexp) 142 143 matched, err := regexp.MatchString(r, name) 144 if err != nil { 145 return err 146 } 147 if !matched { 148 return fmt.Errorf("function name %q is invalid", name) 149 } 150 return nil 151 } 152 153 // validateFnConfigPathSyntax validates syntactic correctness of given functionConfig path 154 // and return an error if it's invalid. 155 func validateFnConfigPathSyntax(p string) error { 156 if strings.TrimSpace(p) == "" { 157 return fmt.Errorf("path must not be empty") 158 } 159 p = filepath.Clean(p) 160 if filepath.IsAbs(p) { 161 return fmt.Errorf("path must be relative") 162 } 163 if strings.Contains(p, "..") { 164 // fn config must not live outside the package directory 165 // Allowing outside path opens up an attack vector that allows 166 // reading any YAML file on package consumer's machine. 167 return fmt.Errorf("path must not be outside the package") 168 } 169 return nil 170 } 171 172 // GetValidatedFnConfigFromPath validates the functionConfig at the path specified by 173 // the package path (pkgPath) and configPath, returning the functionConfig as an 174 // RNode if the validation is successful. 175 func GetValidatedFnConfigFromPath(fsys filesys.FileSystem, pkgPath types.UniquePath, configPath string) (*yaml.RNode, error) { 176 path := filepath.Join(string(pkgPath), configPath) 177 file, err := fsys.Open(path) 178 if err != nil { 179 return nil, fmt.Errorf("functionConfig must exist in the current package") 180 } 181 defer file.Close() 182 reader := kio.ByteReader{Reader: file, PreserveSeqIndent: true, WrapBareSeqNode: true, DisableUnwrapping: true} 183 nodes, err := reader.Read() 184 if err != nil { 185 return nil, fmt.Errorf("failed to read functionConfig %q: %w", configPath, err) 186 } 187 if len(nodes) > 1 { 188 return nil, fmt.Errorf("functionConfig %q must not contain more than one config, got %d", configPath, len(nodes)) 189 } 190 if err := IsKRM(nodes[0]); err != nil { 191 return nil, fmt.Errorf("functionConfig %q: %s", configPath, err.Error()) 192 } 193 return nodes[0], nil 194 } 195 196 // AreKRM validates if given resources are valid KRM resources. 197 func AreKRM(nodes []*yaml.RNode) error { 198 for i := range nodes { 199 if err := IsKRM(nodes[i]); err != nil { 200 path, _, _ := kioutil.GetFileAnnotations(nodes[i]) 201 return fmt.Errorf("%s: %s", path, err.Error()) 202 } 203 } 204 return nil 205 } 206 207 // IsKRM validates if given resource is a valid KRM resource by ensuring 208 // that resource has a valid apiVersion, kind and metadata.name field. 209 // It excludes kustomization resource from KRM check. 210 func IsKRM(n *yaml.RNode) error { 211 if isKustomization(n) { 212 // exclude kustomization files from KRM check 213 // https://github.com/GoogleContainerTools/kpt/issues/2388 214 return nil 215 } 216 meta, err := n.GetMeta() 217 if err != nil { 218 return fmt.Errorf("resource must have `apiVersion`, `kind`, and `name`") 219 } 220 if meta.APIVersion == "" { 221 return fmt.Errorf("resource must have `apiVersion`") 222 } 223 if meta.Kind == "" { 224 return fmt.Errorf("resource must have `kind`") 225 } 226 if meta.Name == "" { 227 return fmt.Errorf("resource must have `metadata.name`") 228 } 229 return nil 230 } 231 232 // isKustomization determines if given YAML is a kustomization file or resource. 233 func isKustomization(n *yaml.RNode) bool { 234 resourcePath, _, err := kioutil.GetFileAnnotations(n) 235 if err == nil { 236 // perform the check only if we are able to reliably 237 // read the file path of the resource 238 resourceFile := filepath.Base(resourcePath) 239 240 for _, kustomizationFileName := range konfig.RecognizedKustomizationFileNames() { 241 if resourceFile == kustomizationFileName { 242 return true 243 } 244 } 245 } 246 meta, err := n.GetMeta() 247 if err != nil { 248 return false 249 } 250 251 if strings.HasPrefix(meta.APIVersion, kustomizationAPIGroup) { 252 return true 253 } 254 255 if meta.APIVersion == "" && meta.Kind == kustomizetypes.KustomizationKind { 256 return true 257 } 258 259 return false 260 } 261 262 // ValidateError is the error returned when validation fails. 263 type ValidateError struct { 264 // Field is the field that causes error 265 Field string 266 // Value is the value of invalid field 267 Value string 268 // Reason is the reason for the error 269 Reason string 270 } 271 272 func (e *ValidateError) Error() string { 273 var sb strings.Builder 274 sb.WriteString(fmt.Sprintf("Kptfile is invalid:\nField: `%s`\n", e.Field)) 275 if e.Value != "" { 276 sb.WriteString(fmt.Sprintf("Value: %q\n", e.Value)) 277 } 278 sb.WriteString(fmt.Sprintf("Reason: %s\n", e.Reason)) 279 return sb.String() 280 }