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  }