istio.io/istio@v0.0.0-20240520182934-d79c90f27776/istioctl/pkg/validate/validate.go (about) 1 // Copyright Istio Authors. 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 validate 16 17 import ( 18 "encoding/json" 19 "errors" 20 "fmt" 21 "io" 22 "os" 23 "path/filepath" 24 "strings" 25 26 "github.com/hashicorp/go-multierror" 27 "github.com/spf13/cobra" 28 "gopkg.in/yaml.v2" 29 metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 30 "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" 31 "k8s.io/apimachinery/pkg/runtime" 32 33 "istio.io/istio/istioctl/pkg/cli" 34 operatoristio "istio.io/istio/operator/pkg/apis/istio" 35 istioV1Alpha1 "istio.io/istio/operator/pkg/apis/istio/v1alpha1" 36 "istio.io/istio/operator/pkg/name" 37 "istio.io/istio/operator/pkg/util" 38 operatorvalidate "istio.io/istio/operator/pkg/validate" 39 "istio.io/istio/pkg/config" 40 "istio.io/istio/pkg/config/constants" 41 "istio.io/istio/pkg/config/protocol" 42 "istio.io/istio/pkg/config/schema/collections" 43 "istio.io/istio/pkg/config/schema/resource" 44 "istio.io/istio/pkg/config/validation" 45 "istio.io/istio/pkg/kube/labels" 46 "istio.io/istio/pkg/log" 47 "istio.io/istio/pkg/slices" 48 "istio.io/istio/pkg/url" 49 ) 50 51 var ( 52 errMissingFilename = errors.New(`error: you must specify resources by --filename. 53 Example resource specifications include: 54 '-f rsrc.yaml' 55 '--filename=rsrc.json'`) 56 57 validFields = map[string]struct{}{ 58 "apiVersion": {}, 59 "kind": {}, 60 "metadata": {}, 61 "spec": {}, 62 "status": {}, 63 } 64 65 serviceProtocolUDP = "UDP" 66 67 fileExtensions = []string{".json", ".yaml", ".yml"} 68 ) 69 70 type validator struct{} 71 72 func checkFields(un *unstructured.Unstructured) error { 73 var errs error 74 for key := range un.Object { 75 if _, ok := validFields[key]; !ok { 76 errs = multierror.Append(errs, fmt.Errorf("unknown field %q", key)) 77 } 78 } 79 return errs 80 } 81 82 func (v *validator) validateResource(istioNamespace, defaultNamespace string, un *unstructured.Unstructured, writer io.Writer) (validation.Warning, error) { 83 gvk := config.GroupVersionKind{ 84 Group: un.GroupVersionKind().Group, 85 Version: un.GroupVersionKind().Version, 86 Kind: un.GroupVersionKind().Kind, 87 } 88 schema, exists := collections.Pilot.FindByGroupVersionAliasesKind(gvk) 89 if exists { 90 obj, err := convertObjectFromUnstructured(schema, un, "") 91 if err != nil { 92 return nil, fmt.Errorf("cannot parse proto message: %v", err) 93 } 94 if err = checkFields(un); err != nil { 95 return nil, err 96 } 97 98 // If object to validate has no namespace, set it (the validity of a CR 99 // may depend on its namespace; for example a VirtualService with exportTo=".") 100 if obj.Namespace == "" { 101 // If the user didn't specify --namespace, and is validating a CR with no namespace, use "default" 102 if defaultNamespace == "" { 103 defaultNamespace = metav1.NamespaceDefault 104 } 105 obj.Namespace = defaultNamespace 106 } 107 108 warnings, err := schema.ValidateConfig(*obj) 109 return warnings, err 110 } 111 112 var errs error 113 if un.IsList() { 114 _ = un.EachListItem(func(item runtime.Object) error { 115 castItem := item.(*unstructured.Unstructured) 116 if castItem.GetKind() == name.ServiceStr { 117 err := v.validateServicePortPrefix(istioNamespace, castItem) 118 if err != nil { 119 errs = multierror.Append(errs, err) 120 } 121 } 122 if castItem.GetKind() == name.DeploymentStr { 123 err := v.validateDeploymentLabel(istioNamespace, castItem, writer) 124 if err != nil { 125 errs = multierror.Append(errs, err) 126 } 127 } 128 return nil 129 }) 130 } 131 132 if errs != nil { 133 return nil, errs 134 } 135 if un.GetKind() == name.ServiceStr { 136 return nil, v.validateServicePortPrefix(istioNamespace, un) 137 } 138 139 if un.GetKind() == name.DeploymentStr { 140 if err := v.validateDeploymentLabel(istioNamespace, un, writer); err != nil { 141 return nil, err 142 } 143 return nil, nil 144 } 145 146 if un.GetAPIVersion() == istioV1Alpha1.IstioOperatorGVK.GroupVersion().String() { 147 if un.GetKind() == istioV1Alpha1.IstioOperatorGVK.Kind { 148 if err := checkFields(un); err != nil { 149 return nil, err 150 } 151 // IstioOperator isn't part of pkg/config/schema/collections, 152 // usual conversion not available. Convert unstructured to string 153 // and ask operator code to check. 154 un.SetCreationTimestamp(metav1.Time{}) // UnmarshalIstioOperator chokes on these 155 by := util.ToYAML(un) 156 iop, err := operatoristio.UnmarshalIstioOperator(by, false) 157 if err != nil { 158 return nil, err 159 } 160 return nil, operatorvalidate.CheckIstioOperator(iop, true) 161 } 162 } 163 164 // Didn't really validate. This is OK, as we often get non-Istio Kubernetes YAML 165 // we can't complain about. 166 167 return nil, nil 168 } 169 170 func (v *validator) validateServicePortPrefix(istioNamespace string, un *unstructured.Unstructured) error { 171 var errs error 172 if un.GetNamespace() == handleNamespace(istioNamespace) { 173 return nil 174 } 175 spec := un.Object["spec"].(map[string]any) 176 if _, ok := spec["ports"]; ok { 177 ports := spec["ports"].([]any) 178 for _, port := range ports { 179 p := port.(map[string]any) 180 if p["protocol"] != nil && strings.EqualFold(p["protocol"].(string), serviceProtocolUDP) { 181 continue 182 } 183 if ap := p["appProtocol"]; ap != nil { 184 if protocol.Parse(ap.(string)).IsUnsupported() { 185 errs = multierror.Append(errs, fmt.Errorf("service %q doesn't follow Istio protocol selection. "+ 186 "This is not recommended, See "+url.ProtocolSelection, fmt.Sprintf("%s/%s/:", un.GetName(), un.GetNamespace()))) 187 } 188 } else { 189 if p["name"] == nil { 190 errs = multierror.Append(errs, fmt.Errorf("service %q has an unnamed port. This is not recommended,"+ 191 " See "+url.DeploymentRequirements, fmt.Sprintf("%s/%s/:", un.GetName(), un.GetNamespace()))) 192 continue 193 } 194 if servicePortPrefixed(p["name"].(string)) { 195 errs = multierror.Append(errs, fmt.Errorf("service %q port %q does not follow the Istio naming convention."+ 196 " See "+url.DeploymentRequirements, fmt.Sprintf("%s/%s/:", un.GetName(), un.GetNamespace()), p["name"].(string))) 197 } 198 } 199 } 200 } 201 if errs != nil { 202 return errs 203 } 204 return nil 205 } 206 207 func (v *validator) validateDeploymentLabel(istioNamespace string, un *unstructured.Unstructured, writer io.Writer) error { 208 if un.GetNamespace() == handleNamespace(istioNamespace) { 209 return nil 210 } 211 objLabels, err := GetTemplateLabels(un) 212 if err != nil { 213 return err 214 } 215 url := fmt.Sprintf("See %s\n", url.DeploymentRequirements) 216 if !labels.HasCanonicalServiceName(objLabels) || !labels.HasCanonicalServiceRevision(objLabels) { 217 fmt.Fprintf(writer, "deployment %q may not provide Istio metrics and telemetry labels: %q. "+url, 218 fmt.Sprintf("%s/%s:", un.GetName(), un.GetNamespace()), objLabels) 219 } 220 return nil 221 } 222 223 // GetTemplateLabels returns spec.template.metadata.labels from Deployment 224 func GetTemplateLabels(u *unstructured.Unstructured) (map[string]string, error) { 225 if spec, ok := u.Object["spec"].(map[string]any); ok { 226 if template, ok := spec["template"].(map[string]any); ok { 227 m, _, err := unstructured.NestedStringMap(template, "metadata", "labels") 228 if err != nil { 229 return nil, err 230 } 231 return m, nil 232 } 233 } 234 return nil, nil 235 } 236 237 func (v *validator) validateFile(path string, istioNamespace *string, defaultNamespace string, reader io.Reader, writer io.Writer, 238 ) (validation.Warning, error) { 239 decoder := yaml.NewDecoder(reader) 240 decoder.SetStrict(true) 241 var errs error 242 var warnings validation.Warning 243 for { 244 // YAML allows non-string keys and the produces generic keys for nested fields 245 raw := make(map[any]any) 246 err := decoder.Decode(&raw) 247 if err == io.EOF { 248 return warnings, errs 249 } 250 if err != nil { 251 errs = multierror.Append(errs, multierror.Prefix(err, fmt.Sprintf("failed to decode file %s: ", path))) 252 return warnings, errs 253 } 254 if len(raw) == 0 { 255 continue 256 } 257 out := transformInterfaceMap(raw) 258 un := unstructured.Unstructured{Object: out} 259 warning, err := v.validateResource(*istioNamespace, defaultNamespace, &un, writer) 260 if err != nil { 261 errs = multierror.Append(errs, multierror.Prefix(err, fmt.Sprintf("%s/%s/%s:", 262 un.GetKind(), un.GetNamespace(), un.GetName()))) 263 } 264 if warning != nil { 265 warnings = multierror.Append(warnings, multierror.Prefix(warning, fmt.Sprintf("%s/%s/%s:", 266 un.GetKind(), un.GetNamespace(), un.GetName()))) 267 } 268 } 269 } 270 271 func isFileFormatValid(file string) bool { 272 ext := filepath.Ext(file) 273 return slices.Contains(fileExtensions, ext) 274 } 275 276 func validateFiles(istioNamespace *string, defaultNamespace string, filenames []string, writer io.Writer) error { 277 if len(filenames) == 0 { 278 return errMissingFilename 279 } 280 281 v := &validator{} 282 283 var errs error 284 var reader io.ReadCloser 285 warningsByFilename := map[string]validation.Warning{} 286 287 processFile := func(path string) { 288 var err error 289 if path == "-" { 290 reader = io.NopCloser(os.Stdin) 291 } else { 292 reader, err = os.Open(path) 293 if err != nil { 294 errs = multierror.Append(errs, fmt.Errorf("cannot read file %q: %v", path, err)) 295 return 296 } 297 } 298 warning, err := v.validateFile(path, istioNamespace, defaultNamespace, reader, writer) 299 if err != nil { 300 errs = multierror.Append(errs, err) 301 } 302 err = reader.Close() 303 if err != nil { 304 log.Infof("file: %s is not closed: %v", path, err) 305 } 306 warningsByFilename[path] = warning 307 } 308 processDirectory := func(directory string, processFile func(string)) error { 309 err := filepath.Walk(directory, func(path string, info os.FileInfo, err error) error { 310 if err != nil { 311 return err 312 } 313 314 if info.IsDir() { 315 return nil 316 } 317 318 if isFileFormatValid(path) { 319 processFile(path) 320 } 321 322 return nil 323 }) 324 return err 325 } 326 327 processedFiles := map[string]bool{} 328 for _, filename := range filenames { 329 var isDir bool 330 if filename != "-" { 331 fi, err := os.Stat(filename) 332 if err != nil { 333 errs = multierror.Append(errs, fmt.Errorf("cannot stat file %q: %v", filename, err)) 334 continue 335 } 336 isDir = fi.IsDir() 337 } 338 339 if !isDir { 340 processFile(filename) 341 processedFiles[filename] = true 342 continue 343 } 344 if err := processDirectory(filename, func(path string) { 345 processFile(path) 346 processedFiles[path] = true 347 }); err != nil { 348 errs = multierror.Append(errs, err) 349 } 350 } 351 filenames = []string{} 352 for p := range processedFiles { 353 filenames = append(filenames, p) 354 } 355 356 if errs != nil { 357 // Display warnings we encountered as well 358 for _, fname := range filenames { 359 if w := warningsByFilename[fname]; w != nil { 360 if fname == "-" { 361 _, _ = fmt.Fprint(writer, warningToString(w)) 362 break 363 } 364 _, _ = fmt.Fprintf(writer, "%q has warnings: %v\n", fname, warningToString(w)) 365 } 366 } 367 return errs 368 } 369 for _, fname := range filenames { 370 if fname == "-" { 371 if w := warningsByFilename[fname]; w != nil { 372 _, _ = fmt.Fprint(writer, warningToString(w)) 373 } else { 374 _, _ = fmt.Fprintf(writer, "validation succeed\n") 375 } 376 break 377 } 378 379 if w := warningsByFilename[fname]; w != nil { 380 _, _ = fmt.Fprintf(writer, "%q has warnings: %v\n", fname, warningToString(w)) 381 } else { 382 _, _ = fmt.Fprintf(writer, "%q is valid\n", fname) 383 } 384 } 385 386 return nil 387 } 388 389 // NewValidateCommand creates a new command for validating Istio k8s resources. 390 func NewValidateCommand(ctx cli.Context) *cobra.Command { 391 var filenames []string 392 var referential bool 393 394 c := &cobra.Command{ 395 Use: "validate -f FILENAME [options]", 396 Aliases: []string{"v"}, 397 Short: "Validate Istio policy and rules files", 398 Example: ` # Validate bookinfo-gateway.yaml 399 istioctl validate -f samples/bookinfo/networking/bookinfo-gateway.yaml 400 401 # Validate bookinfo-gateway.yaml with shorthand syntax 402 istioctl v -f samples/bookinfo/networking/bookinfo-gateway.yaml 403 404 # Validate all yaml files under samples/bookinfo/networking directory 405 istioctl validate -f samples/bookinfo/networking 406 407 # Validate current deployments under 'default' namespace within the cluster 408 kubectl get deployments -o yaml | istioctl validate -f - 409 410 # Validate current services under 'default' namespace within the cluster 411 kubectl get services -o yaml | istioctl validate -f - 412 413 # Also see the related command 'istioctl analyze' 414 istioctl analyze samples/bookinfo/networking/bookinfo-gateway.yaml 415 `, 416 Args: cobra.NoArgs, 417 RunE: func(c *cobra.Command, _ []string) error { 418 istioNamespace := ctx.IstioNamespace() 419 defaultNamespace := ctx.NamespaceOrDefault("") 420 return validateFiles(&istioNamespace, defaultNamespace, filenames, c.OutOrStderr()) 421 }, 422 } 423 424 flags := c.PersistentFlags() 425 flags.StringSliceVarP(&filenames, "filename", "f", nil, "Inputs of files to validate") 426 flags.BoolVarP(&referential, "referential", "x", true, "Enable structural validation for policy and telemetry") 427 _ = flags.MarkHidden("referential") 428 return c 429 } 430 431 func warningToString(w validation.Warning) string { 432 we, ok := w.(*multierror.Error) 433 if ok { 434 we.ErrorFormat = func(i []error) string { 435 points := make([]string, len(i)) 436 for i, err := range i { 437 points[i] = fmt.Sprintf("* %s", err) 438 } 439 440 return fmt.Sprintf( 441 "\n\t%s\n", 442 strings.Join(points, "\n\t")) 443 } 444 } 445 return w.Error() 446 } 447 448 func transformInterfaceArray(in []any) []any { 449 out := make([]any, len(in)) 450 for i, v := range in { 451 out[i] = transformMapValue(v) 452 } 453 return out 454 } 455 456 func transformInterfaceMap(in map[any]any) map[string]any { 457 out := make(map[string]any, len(in)) 458 for k, v := range in { 459 out[fmt.Sprintf("%v", k)] = transformMapValue(v) 460 } 461 return out 462 } 463 464 func transformMapValue(in any) any { 465 switch v := in.(type) { 466 case []any: 467 return transformInterfaceArray(v) 468 case map[any]any: 469 return transformInterfaceMap(v) 470 default: 471 return v 472 } 473 } 474 475 func servicePortPrefixed(n string) bool { 476 i := strings.IndexByte(n, '-') 477 if i >= 0 { 478 n = n[:i] 479 } 480 p := protocol.Parse(n) 481 return p == protocol.Unsupported 482 } 483 484 func handleNamespace(istioNamespace string) string { 485 if istioNamespace == "" { 486 istioNamespace = constants.IstioSystemNamespace 487 } 488 return istioNamespace 489 } 490 491 // TODO(nmittler): Remove this once Pilot migrates to galley schema. 492 func convertObjectFromUnstructured(schema resource.Schema, un *unstructured.Unstructured, domain string) (*config.Config, error) { 493 data, err := fromSchemaAndJSONMap(schema, un.Object["spec"]) 494 if err != nil { 495 return nil, err 496 } 497 498 return &config.Config{ 499 Meta: config.Meta{ 500 GroupVersionKind: schema.GroupVersionKind(), 501 Name: un.GetName(), 502 Namespace: un.GetNamespace(), 503 Domain: domain, 504 Labels: un.GetLabels(), 505 Annotations: un.GetAnnotations(), 506 ResourceVersion: un.GetResourceVersion(), 507 CreationTimestamp: un.GetCreationTimestamp().Time, 508 }, 509 Spec: data, 510 }, nil 511 } 512 513 // TODO(nmittler): Remove this once Pilot migrates to galley schema. 514 func fromSchemaAndJSONMap(schema resource.Schema, data any) (config.Spec, error) { 515 // Marshal to json bytes 516 str, err := json.Marshal(data) 517 if err != nil { 518 return nil, err 519 } 520 out, err := schema.NewInstance() 521 if err != nil { 522 return nil, err 523 } 524 if err = config.ApplyJSONStrict(out, string(str)); err != nil { 525 return nil, err 526 } 527 return out, nil 528 }