1// Copyright 2018 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
15package validate
16
17import (
18	"encoding/json"
19	"errors"
20	"fmt"
21	"io"
22	"os"
23	"strings"
24
25	"github.com/gogo/protobuf/proto"
26	"github.com/hashicorp/go-multierror"
27	"github.com/spf13/cobra"
28	"gopkg.in/yaml.v2"
29
30	"istio.io/pkg/log"
31
32	mixercrd "istio.io/istio/mixer/pkg/config/crd"
33	mixerstore "istio.io/istio/mixer/pkg/config/store"
34	"istio.io/istio/mixer/pkg/runtime/config/constant"
35	mixervalidate "istio.io/istio/mixer/pkg/validate"
36	"istio.io/istio/pilot/pkg/model"
37	"istio.io/istio/pilot/pkg/serviceregistry/kube/controller"
38	"istio.io/istio/pkg/config/protocol"
39	"istio.io/istio/pkg/config/schema/collection"
40	"istio.io/istio/pkg/config/schema/collections"
41	"istio.io/istio/pkg/config/schema/resource"
42	"istio.io/istio/pkg/util/gogoprotomarshal"
43
44	operator_istio "istio.io/istio/operator/pkg/apis/istio"
45	operator_validate "istio.io/istio/operator/pkg/validate"
46
47	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
48	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
49	"k8s.io/apimachinery/pkg/runtime"
50)
51
52var (
53	mixerAPIVersion = "config.istio.io/v1alpha2"
54
55	errMissingFilename = errors.New(`error: you must specify resources by --filename.
56Example resource specifications include:
57   '-f rsrc.yaml'
58   '--filename=rsrc.json'`)
59	errKindNotSupported = errors.New("kind is not supported")
60
61	validFields = map[string]struct{}{
62		"apiVersion": {},
63		"kind":       {},
64		"metadata":   {},
65		"spec":       {},
66		"status":     {},
67	}
68
69	validMixerKinds = map[string]struct{}{
70		constant.RulesKind:             {},
71		constant.AdapterKind:           {},
72		constant.TemplateKind:          {},
73		constant.HandlerKind:           {},
74		constant.InstanceKind:          {},
75		constant.AttributeManifestKind: {},
76	}
77
78	istioDeploymentLabel = []string{
79		"app",
80		"version",
81	}
82	serviceProtocolUDP = "UDP"
83)
84
85type validator struct {
86	mixerValidator mixerstore.BackendValidator
87}
88
89func checkFields(un *unstructured.Unstructured) error {
90	var errs error
91	for key := range un.Object {
92		if _, ok := validFields[key]; !ok {
93			errs = multierror.Append(errs, fmt.Errorf("unknown field %q", key))
94		}
95	}
96	return errs
97}
98
99func (v *validator) validateResource(istioNamespace string, un *unstructured.Unstructured) error {
100	gvk := resource.GroupVersionKind{
101		Group:   un.GroupVersionKind().Group,
102		Version: un.GroupVersionKind().Version,
103		Kind:    un.GroupVersionKind().Kind,
104	}
105	// TODO(jasonwzm) remove this when multi-version is supported. v1beta1 shares the same
106	// schema as v1lalpha3. Fake conversion and validate against v1alpha3.
107	if gvk.Group == "networking.istio.io" && gvk.Version == "v1beta1" {
108		gvk.Version = "v1alpha3"
109	}
110	schema, exists := collections.Pilot.FindByGroupVersionKind(gvk)
111	if exists {
112		obj, err := convertObjectFromUnstructured(schema, un, "")
113		if err != nil {
114			return fmt.Errorf("cannot parse proto message: %v", err)
115		}
116		if err = checkFields(un); err != nil {
117			return err
118		}
119		return schema.Resource().ValidateProto(obj.Name, obj.Namespace, obj.Spec)
120	}
121
122	if v.mixerValidator != nil && un.GetAPIVersion() == mixerAPIVersion {
123		if !v.mixerValidator.SupportsKind(un.GetKind()) {
124			return errKindNotSupported
125		}
126		if err := checkFields(un); err != nil {
127			return err
128		}
129		if _, ok := validMixerKinds[un.GetKind()]; !ok {
130			log.Warnf("deprecated Mixer kind %q, please use %q or %q instead", un.GetKind(),
131				constant.HandlerKind, constant.InstanceKind)
132		}
133
134		return v.mixerValidator.Validate(&mixerstore.BackendEvent{
135			Type: mixerstore.Update,
136			Key: mixerstore.Key{
137				Name:      un.GetName(),
138				Namespace: un.GetNamespace(),
139				Kind:      un.GetKind(),
140			},
141			Value: mixercrd.ToBackEndResource(un),
142		})
143	}
144	var errs error
145	if un.IsList() {
146		_ = un.EachListItem(func(item runtime.Object) error {
147			castItem := item.(*unstructured.Unstructured)
148			if castItem.GetKind() == "Service" {
149				err := v.validateServicePortPrefix(istioNamespace, castItem)
150				if err != nil {
151					errs = multierror.Append(errs, err)
152				}
153			}
154			if castItem.GetKind() == "Deployment" {
155				v.validateDeploymentLabel(istioNamespace, castItem)
156			}
157			return nil
158		})
159	}
160
161	if errs != nil {
162		return errs
163	}
164	if un.GetKind() == "Service" {
165		return v.validateServicePortPrefix(istioNamespace, un)
166	}
167
168	if un.GetKind() == "Deployment" {
169		v.validateDeploymentLabel(istioNamespace, un)
170		return nil
171	}
172
173	if un.GetAPIVersion() == "install.istio.io/v1alpha1" {
174		if un.GetKind() == "IstioOperator" {
175			if err := checkFields(un); err != nil {
176				return err
177			}
178
179			// IstioOperator isn't part of pkg/config/schema/collections,
180			// usual conversion not available.  Convert unstructured to string
181			// and ask operator code to check.
182
183			un.SetCreationTimestamp(metav1.Time{}) // UnmarshalIstioOperator chokes on these
184			by, err := json.Marshal(un)
185			if err != nil {
186				return err
187			}
188
189			iop, err := operator_istio.UnmarshalIstioOperator(string(by))
190			if err != nil {
191				return err
192			}
193
194			return operator_validate.CheckIstioOperator(iop, true)
195		}
196	}
197
198	// Didn't really validate.  This is OK, as we often get non-Istio Kubernetes YAML
199	// we can't complain about.
200
201	return nil
202}
203
204func (v *validator) validateServicePortPrefix(istioNamespace string, un *unstructured.Unstructured) error {
205	var errs error
206	if un.GetNamespace() == handleNamespace(istioNamespace) {
207		return nil
208	}
209	spec := un.Object["spec"].(map[string]interface{})
210	if _, ok := spec["ports"]; ok {
211		ports := spec["ports"].([]interface{})
212		for _, port := range ports {
213			p := port.(map[string]interface{})
214			if p["protocol"] != nil && strings.EqualFold(p["protocol"].(string), serviceProtocolUDP) {
215				continue
216			}
217			if p["name"] == nil {
218				errs = multierror.Append(errs, fmt.Errorf("service %q has an unnamed port. This is not recommended,"+
219					" see https://istio.io/docs/setup/kubernetes/prepare/requirements/", fmt.Sprintf("%s/%s/:",
220					un.GetName(), un.GetNamespace())))
221				continue
222			}
223			if servicePortPrefixed(p["name"].(string)) {
224				errs = multierror.Append(errs, fmt.Errorf("service %q port %q does not follow the Istio naming convention."+
225					" See https://istio.io/docs/setup/kubernetes/prepare/requirements/", fmt.Sprintf("%s/%s/:",
226					un.GetName(), un.GetNamespace()), p["name"].(string)))
227			}
228		}
229	}
230	if errs != nil {
231		return errs
232	}
233	return nil
234}
235
236func (v *validator) validateDeploymentLabel(istioNamespace string, un *unstructured.Unstructured) {
237	if un.GetNamespace() == handleNamespace(istioNamespace) {
238		return
239	}
240	labels := un.GetLabels()
241	for _, l := range istioDeploymentLabel {
242		if _, ok := labels[l]; !ok {
243			log.Warnf("deployment %q may not provide Istio metrics and telemetry without label %q."+
244				" See https://istio.io/docs/setup/kubernetes/prepare/requirements/ \n", fmt.Sprintf("%s/%s:",
245				un.GetName(), un.GetNamespace()), l)
246		}
247	}
248}
249
250func (v *validator) validateFile(istioNamespace *string, reader io.Reader) error {
251	decoder := yaml.NewDecoder(reader)
252	var errs error
253	for {
254		// YAML allows non-string keys and the produces generic keys for nested fields
255		raw := make(map[interface{}]interface{})
256		err := decoder.Decode(&raw)
257		if err == io.EOF {
258			return errs
259		}
260		if err != nil {
261			errs = multierror.Append(errs, err)
262			return errs
263		}
264		if len(raw) == 0 {
265			continue
266		}
267		out := transformInterfaceMap(raw)
268		un := unstructured.Unstructured{Object: out}
269		err = v.validateResource(*istioNamespace, &un)
270		if err != nil {
271			errs = multierror.Append(errs, multierror.Prefix(err, fmt.Sprintf("%s/%s/%s:",
272				un.GetKind(), un.GetNamespace(), un.GetName())))
273		}
274	}
275}
276
277func validateFiles(istioNamespace *string, filenames []string, referential bool, writer io.Writer) error {
278	if len(filenames) == 0 {
279		return errMissingFilename
280	}
281
282	v := &validator{
283		mixerValidator: mixervalidate.NewDefaultValidator(referential),
284	}
285
286	var errs, err error
287	var reader io.Reader
288	for _, filename := range filenames {
289		if filename == "-" {
290			reader = os.Stdin
291		} else {
292			reader, err = os.Open(filename)
293		}
294		if err != nil {
295			errs = multierror.Append(errs, fmt.Errorf("cannot read file %q: %v", filename, err))
296			continue
297		}
298		err = v.validateFile(istioNamespace, reader)
299		if err != nil {
300			errs = multierror.Append(errs, err)
301		}
302	}
303	if errs != nil {
304		return errs
305	}
306	for _, fname := range filenames {
307		if fname == "-" {
308			_, _ = fmt.Fprintf(writer, "validation succeed\n")
309			break
310		} else {
311			_, _ = fmt.Fprintf(writer, "%q is valid\n", fname)
312		}
313	}
314
315	return nil
316}
317
318// NewValidateCommand creates a new command for validating Istio k8s resources.
319func NewValidateCommand(istioNamespace *string) *cobra.Command {
320	var filenames []string
321	var referential bool
322
323	c := &cobra.Command{
324		Use:   "validate -f FILENAME [options]",
325		Short: "Validate Istio policy and rules (NOTE: validate is deprecated and will be removed in 1.6. Use 'istioctl analyze' to validate configuration.)",
326		Example: `
327		# Validate bookinfo-gateway.yaml
328		istioctl validate -f bookinfo-gateway.yaml
329
330		# Validate current deployments under 'default' namespace within the cluster
331		kubectl get deployments -o yaml |istioctl validate -f -
332
333		# Validate current services under 'default' namespace within the cluster
334		kubectl get services -o yaml |istioctl validate -f -
335
336		# Also see the related command 'istioctl analyze'
337		istioctl analyze samples/bookinfo/networking/bookinfo-gateway.yaml
338`,
339		Args: cobra.NoArgs,
340		RunE: func(c *cobra.Command, _ []string) error {
341			return validateFiles(istioNamespace, filenames, referential, c.OutOrStderr())
342		},
343	}
344
345	flags := c.PersistentFlags()
346	flags.StringSliceVarP(&filenames, "filename", "f", nil, "Names of files to validate")
347	flags.BoolVarP(&referential, "referential", "x", true, "Enable structural validation for policy and telemetry")
348
349	return c
350}
351
352func transformInterfaceArray(in []interface{}) []interface{} {
353	out := make([]interface{}, len(in))
354	for i, v := range in {
355		out[i] = transformMapValue(v)
356	}
357	return out
358}
359
360func transformInterfaceMap(in map[interface{}]interface{}) map[string]interface{} {
361	out := make(map[string]interface{}, len(in))
362	for k, v := range in {
363		out[fmt.Sprintf("%v", k)] = transformMapValue(v)
364	}
365	return out
366}
367
368func transformMapValue(in interface{}) interface{} {
369	switch v := in.(type) {
370	case []interface{}:
371		return transformInterfaceArray(v)
372	case map[interface{}]interface{}:
373		return transformInterfaceMap(v)
374	default:
375		return v
376	}
377}
378
379func servicePortPrefixed(n string) bool {
380	i := strings.IndexByte(n, '-')
381	if i >= 0 {
382		n = n[:i]
383	}
384	p := protocol.Parse(n)
385	return p == protocol.Unsupported
386}
387func handleNamespace(istioNamespace string) string {
388	if istioNamespace == "" {
389		istioNamespace = controller.IstioNamespace
390	}
391	return istioNamespace
392}
393
394// TODO(nmittler): Remove this once Pilot migrates to galley schema.
395func convertObjectFromUnstructured(schema collection.Schema, un *unstructured.Unstructured, domain string) (*model.Config, error) {
396	data, err := fromSchemaAndJSONMap(schema, un.Object["spec"])
397	if err != nil {
398		return nil, err
399	}
400
401	return &model.Config{
402		ConfigMeta: model.ConfigMeta{
403			Type:              schema.Resource().Kind(),
404			Group:             schema.Resource().Group(),
405			Version:           schema.Resource().Version(),
406			Name:              un.GetName(),
407			Namespace:         un.GetNamespace(),
408			Domain:            domain,
409			Labels:            un.GetLabels(),
410			Annotations:       un.GetAnnotations(),
411			ResourceVersion:   un.GetResourceVersion(),
412			CreationTimestamp: un.GetCreationTimestamp().Time,
413		},
414		Spec: data,
415	}, nil
416}
417
418// TODO(nmittler): Remove this once Pilot migrates to galley schema.
419func fromSchemaAndYAML(schema collection.Schema, yml string) (proto.Message, error) {
420	pb, err := schema.Resource().NewProtoInstance()
421	if err != nil {
422		return nil, err
423	}
424	if err = gogoprotomarshal.ApplyYAMLStrict(yml, pb); err != nil {
425		return nil, err
426	}
427	return pb, nil
428}
429
430// TODO(nmittler): Remove this once Pilot migrates to galley schema.
431func fromSchemaAndJSONMap(schema collection.Schema, data interface{}) (proto.Message, error) {
432	// Marshal to YAML bytes
433	str, err := yaml.Marshal(data)
434	if err != nil {
435		return nil, err
436	}
437	out, err := fromSchemaAndYAML(schema, string(str))
438	if err != nil {
439		return nil, multierror.Prefix(err, fmt.Sprintf("YAML decoding error: %v", string(str)))
440	}
441	return out, nil
442}
443