istio.io/istio@v0.0.0-20240520182934-d79c90f27776/istioctl/pkg/analyze/analyze.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 analyze
    16  
    17  import (
    18  	"context"
    19  	"fmt"
    20  	"os"
    21  	"path/filepath"
    22  	"runtime"
    23  	"sort"
    24  	"strings"
    25  	"time"
    26  
    27  	"github.com/mattn/go-isatty"
    28  	"github.com/spf13/cobra"
    29  	"k8s.io/apimachinery/pkg/api/errors"
    30  	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
    31  	"k8s.io/client-go/tools/clientcmd"
    32  
    33  	"istio.io/istio/istioctl/pkg/cli"
    34  	"istio.io/istio/istioctl/pkg/util"
    35  	"istio.io/istio/istioctl/pkg/util/formatting"
    36  	"istio.io/istio/pkg/cluster"
    37  	"istio.io/istio/pkg/config/analysis"
    38  	"istio.io/istio/pkg/config/analysis/analyzers"
    39  	"istio.io/istio/pkg/config/analysis/diag"
    40  	"istio.io/istio/pkg/config/analysis/local"
    41  	"istio.io/istio/pkg/config/analysis/msg"
    42  	"istio.io/istio/pkg/config/resource"
    43  	"istio.io/istio/pkg/kube"
    44  	"istio.io/istio/pkg/kube/multicluster"
    45  	"istio.io/istio/pkg/log"
    46  	"istio.io/istio/pkg/url"
    47  )
    48  
    49  // AnalyzerFoundIssuesError indicates that at least one analyzer found problems.
    50  type AnalyzerFoundIssuesError struct{}
    51  
    52  // FileParseError indicates a provided file was unable to be parsed.
    53  type FileParseError struct{}
    54  
    55  const FileParseString = "Some files couldn't be parsed."
    56  
    57  func (f AnalyzerFoundIssuesError) Error() string {
    58  	var sb strings.Builder
    59  	sb.WriteString(fmt.Sprintf("Analyzers found issues when analyzing %s.\n", analyzeTargetAsString()))
    60  	sb.WriteString(fmt.Sprintf("See %s for more information about causes and resolutions.", url.ConfigAnalysis))
    61  	return sb.String()
    62  }
    63  
    64  func (f FileParseError) Error() string {
    65  	return FileParseString
    66  }
    67  
    68  var (
    69  	listAnalyzers     bool
    70  	useKube           bool
    71  	failureThreshold  = formatting.MessageThreshold{Level: diag.Error} // messages at least this level will generate an error exit code
    72  	outputThreshold   = formatting.MessageThreshold{Level: diag.Info}  // messages at least this level will be included in the output
    73  	colorize          bool
    74  	msgOutputFormat   string
    75  	meshCfgFile       string
    76  	selectedNamespace string
    77  	allNamespaces     bool
    78  	suppress          []string
    79  	analysisTimeout   time.Duration
    80  	recursive         bool
    81  	ignoreUnknown     bool
    82  	revisionSpecified string
    83  
    84  	fileExtensions = []string{".json", ".yaml", ".yml"}
    85  )
    86  
    87  // Analyze command
    88  func Analyze(ctx cli.Context) *cobra.Command {
    89  	var verbose bool
    90  	analysisCmd := &cobra.Command{
    91  		Use:   "analyze <file>...",
    92  		Short: "Analyze Istio configuration and print validation messages",
    93  		Example: `  # Analyze the current live cluster
    94    istioctl analyze
    95  
    96    # Analyze the current live cluster for a specific revision
    97    istioctl analyze --revision 1-16
    98  
    99    # Analyze the current live cluster, simulating the effect of applying additional yaml files
   100    istioctl analyze a.yaml b.yaml my-app-config/
   101  
   102    # Analyze the current live cluster, simulating the effect of applying a directory of config recursively
   103    istioctl analyze --recursive my-istio-config/
   104  
   105    # Analyze yaml files without connecting to a live cluster
   106    istioctl analyze --use-kube=false a.yaml b.yaml my-app-config/
   107  
   108    # Analyze the current live cluster and suppress PodMissingProxy for pod mypod in namespace 'testing'.
   109    istioctl analyze -S "IST0103=Pod mypod.testing"
   110  
   111    # Analyze the current live cluster and suppress PodMissingProxy for all pods in namespace 'testing',
   112    # and suppress MisplacedAnnotation on deployment foobar in namespace default.
   113    istioctl analyze -S "IST0103=Pod *.testing" -S "IST0107=Deployment foobar.default"
   114  
   115    # List available analyzers
   116    istioctl analyze -L`,
   117  		RunE: func(cmd *cobra.Command, args []string) error {
   118  			msgOutputFormat = strings.ToLower(msgOutputFormat)
   119  			_, ok := formatting.MsgOutputFormats[msgOutputFormat]
   120  			if !ok {
   121  				return util.CommandParseError{
   122  					Err: fmt.Errorf("%s not a valid option for format. See istioctl analyze --help", msgOutputFormat),
   123  				}
   124  			}
   125  
   126  			if listAnalyzers {
   127  				fmt.Print(AnalyzersAsString(analyzers.All()))
   128  				return nil
   129  			}
   130  
   131  			readers, err := gatherFiles(cmd, args)
   132  			if err != nil {
   133  				return err
   134  			}
   135  			cancel := make(chan struct{})
   136  
   137  			// We use the "namespace" arg that's provided as part of root istioctl as a flag for specifying what namespace to use
   138  			// for file resources that don't have one specified.
   139  			selectedNamespace = ctx.Namespace()
   140  			if useKube {
   141  				// apply default namespace if not specified and useKube is true
   142  				selectedNamespace = ctx.NamespaceOrDefault(selectedNamespace)
   143  				if selectedNamespace != "" {
   144  					client, err := ctx.CLIClient()
   145  					if err != nil {
   146  						return err
   147  					}
   148  					_, err = client.Kube().CoreV1().Namespaces().Get(context.TODO(), selectedNamespace, metav1.GetOptions{})
   149  					if errors.IsNotFound(err) {
   150  						fmt.Fprintf(cmd.ErrOrStderr(), "namespace %q not found\n", ctx.Namespace())
   151  						return nil
   152  					} else if err != nil {
   153  						return err
   154  					}
   155  				}
   156  			}
   157  
   158  			// If we've explicitly asked for all namespaces, blank the selectedNamespace var out
   159  			// If the user hasn't specified a namespace, use the default namespace
   160  			if allNamespaces {
   161  				selectedNamespace = ""
   162  			} else if selectedNamespace == "" {
   163  				selectedNamespace = metav1.NamespaceDefault
   164  			}
   165  
   166  			sa := local.NewIstiodAnalyzer(analyzers.AllCombined(),
   167  				resource.Namespace(selectedNamespace),
   168  				resource.Namespace(ctx.IstioNamespace()), nil)
   169  
   170  			// Check for suppressions and add them to our SourceAnalyzer
   171  			suppressions := make([]local.AnalysisSuppression, 0, len(suppress))
   172  			for _, s := range suppress {
   173  				parts := strings.Split(s, "=")
   174  				if len(parts) != 2 {
   175  					return fmt.Errorf("%s is not a valid suppression value. See istioctl analyze --help", s)
   176  				}
   177  				// Check to see if the supplied code is valid. If not, emit a
   178  				// warning but continue.
   179  				codeIsValid := false
   180  				for _, at := range msg.All() {
   181  					if at.Code() == parts[0] {
   182  						codeIsValid = true
   183  						break
   184  					}
   185  				}
   186  
   187  				if !codeIsValid {
   188  					fmt.Fprintf(cmd.ErrOrStderr(), "Warning: Supplied message code '%s' is an unknown message code and will not have any effect.\n", parts[0])
   189  				}
   190  				suppressions = append(suppressions, local.AnalysisSuppression{
   191  					Code:         parts[0],
   192  					ResourceName: parts[1],
   193  				})
   194  			}
   195  			sa.SetSuppressions(suppressions)
   196  
   197  			// If we're using kube, use that as a base source.
   198  			if useKube {
   199  				clients, err := getClients(ctx)
   200  				if err != nil {
   201  					return err
   202  				}
   203  				for _, c := range clients {
   204  					k := kube.EnableCrdWatcher(c.client)
   205  					sa.AddRunningKubeSourceWithRevision(k, revisionSpecified, c.remote)
   206  				}
   207  			}
   208  
   209  			// If we explicitly specify mesh config, use it.
   210  			// This takes precedence over default mesh config or mesh config from a running Kube instance.
   211  			if meshCfgFile != "" {
   212  				_ = sa.AddFileKubeMeshConfig(meshCfgFile)
   213  			}
   214  
   215  			// If we're not using kube (files only), add defaults for some resources we expect to be provided by Istio
   216  			if !useKube {
   217  				err := sa.AddDefaultResources()
   218  				if err != nil {
   219  					return err
   220  				}
   221  			}
   222  
   223  			// If files are provided, treat them (collectively) as a source.
   224  			parseErrors := 0
   225  			if len(readers) > 0 {
   226  				if err = sa.AddReaderKubeSource(readers); err != nil {
   227  					fmt.Fprintf(cmd.ErrOrStderr(), "Error(s) adding files: %v", err)
   228  					parseErrors++
   229  				}
   230  			}
   231  
   232  			// Do the analysis
   233  			result, err := sa.Analyze(cancel)
   234  			if err != nil {
   235  				return err
   236  			}
   237  
   238  			// Maybe output details about which analyzers ran
   239  			if verbose {
   240  				fmt.Fprintf(cmd.ErrOrStderr(), "Analyzed resources in %s\n", analyzeTargetAsString())
   241  
   242  				if len(result.SkippedAnalyzers) > 0 {
   243  					fmt.Fprintln(cmd.ErrOrStderr(), "Skipped analyzers:")
   244  					for _, a := range result.SkippedAnalyzers {
   245  						fmt.Fprintln(cmd.ErrOrStderr(), "\t", a)
   246  					}
   247  				}
   248  				if len(result.ExecutedAnalyzers) > 0 {
   249  					fmt.Fprintln(cmd.ErrOrStderr(), "Executed analyzers:")
   250  					for _, a := range result.ExecutedAnalyzers {
   251  						fmt.Fprintln(cmd.ErrOrStderr(), "\t", a)
   252  					}
   253  				}
   254  				fmt.Fprintln(cmd.ErrOrStderr())
   255  			}
   256  
   257  			// Get messages for output
   258  			outputMessages := result.Messages.SetDocRef("istioctl-analyze").FilterOutLowerThan(outputThreshold.Level)
   259  
   260  			// Print all the messages to stdout in the specified format
   261  			output, err := formatting.Print(outputMessages, msgOutputFormat, colorize)
   262  			if err != nil {
   263  				return err
   264  			}
   265  			fmt.Fprintln(cmd.OutOrStdout(), output)
   266  
   267  			// An extra message on success
   268  			if len(outputMessages) == 0 {
   269  				if parseErrors == 0 {
   270  					if len(readers) > 0 {
   271  						var files []string
   272  						for _, r := range readers {
   273  							files = append(files, r.Name)
   274  						}
   275  						fmt.Fprintf(cmd.ErrOrStderr(), "\u2714 No validation issues found when analyzing %s.\n", strings.Join(files, "\n"))
   276  					} else {
   277  						fmt.Fprintf(cmd.ErrOrStderr(), "\u2714 No validation issues found when analyzing %s.\n", analyzeTargetAsString())
   278  					}
   279  				} else {
   280  					fileOrFiles := "files"
   281  					if parseErrors == 1 {
   282  						fileOrFiles = "file"
   283  					}
   284  					fmt.Fprintf(cmd.ErrOrStderr(),
   285  						"No validation issues found when analyzing %s (but %d %s could not be parsed).\n",
   286  						analyzeTargetAsString(),
   287  						parseErrors,
   288  						fileOrFiles,
   289  					)
   290  				}
   291  			}
   292  
   293  			// Return code is based on the unfiltered validation message list/parse errors
   294  			// We're intentionally keeping failure threshold and output threshold decoupled for now
   295  			var returnError error
   296  			if msgOutputFormat == formatting.LogFormat {
   297  				returnError = errorIfMessagesExceedThreshold(result.Messages)
   298  				if returnError == nil && parseErrors > 0 && !ignoreUnknown {
   299  					returnError = FileParseError{}
   300  				}
   301  			}
   302  			return returnError
   303  		},
   304  	}
   305  
   306  	analysisCmd.PersistentFlags().BoolVarP(&listAnalyzers, "list-analyzers", "L", false,
   307  		"List the analyzers available to run. Suppresses normal execution.")
   308  	analysisCmd.PersistentFlags().BoolVarP(&useKube, "use-kube", "k", true,
   309  		"Use live Kubernetes cluster for analysis. Set --use-kube=false to analyze files only.")
   310  	analysisCmd.PersistentFlags().BoolVar(&colorize, "color", formatting.IstioctlColorDefault(analysisCmd.OutOrStdout()),
   311  		"Default true.  Disable with '=false' or set $TERM to dumb")
   312  	analysisCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false,
   313  		"Enable verbose output")
   314  	analysisCmd.PersistentFlags().Var(&failureThreshold, "failure-threshold",
   315  		fmt.Sprintf("The severity level of analysis at which to set a non-zero exit code. Valid values: %v", diag.GetAllLevelStrings()))
   316  	analysisCmd.PersistentFlags().Var(&outputThreshold, "output-threshold",
   317  		fmt.Sprintf("The severity level of analysis at which to display messages. Valid values: %v", diag.GetAllLevelStrings()))
   318  	analysisCmd.PersistentFlags().StringVarP(&msgOutputFormat, "output", "o", formatting.LogFormat,
   319  		fmt.Sprintf("Output format: one of %v", formatting.MsgOutputFormatKeys))
   320  	analysisCmd.PersistentFlags().StringVar(&meshCfgFile, "meshConfigFile", "",
   321  		"Overrides the mesh config values to use for analysis.")
   322  	analysisCmd.PersistentFlags().BoolVarP(&allNamespaces, "all-namespaces", "A", false,
   323  		"Analyze all namespaces")
   324  	analysisCmd.PersistentFlags().StringArrayVarP(&suppress, "suppress", "S", []string{},
   325  		"Suppress reporting a message code on a specific resource. Values are supplied in the form "+
   326  			`<code>=<resource> (e.g. '--suppress "IST0102=DestinationRule primary-dr.default"'). Can be repeated. `+
   327  			`You can include the wildcard character '*' to support a partial match (e.g. '--suppress "IST0102=DestinationRule *.default" ).`)
   328  	analysisCmd.PersistentFlags().DurationVar(&analysisTimeout, "timeout", 30*time.Second,
   329  		"The duration to wait before failing")
   330  	analysisCmd.PersistentFlags().BoolVarP(&recursive, "recursive", "R", false,
   331  		"Process directory arguments recursively. Useful when you want to analyze related manifests organized within the same directory.")
   332  	analysisCmd.PersistentFlags().BoolVar(&ignoreUnknown, "ignore-unknown", false,
   333  		"Don't complain about un-parseable input documents, for cases where analyze should run only on k8s compliant inputs.")
   334  	analysisCmd.PersistentFlags().StringVarP(&revisionSpecified, "revision", "", "default",
   335  		"analyze a specific revision deployed.")
   336  	return analysisCmd
   337  }
   338  
   339  func gatherFiles(cmd *cobra.Command, args []string) ([]local.ReaderSource, error) {
   340  	var readers []local.ReaderSource
   341  	for _, f := range args {
   342  		var r *os.File
   343  
   344  		// Handle "-" as stdin as a special case.
   345  		if f == "-" {
   346  			if isatty.IsTerminal(os.Stdin.Fd()) && !isJSONorYAMLOutputFormat() {
   347  				fmt.Fprint(cmd.OutOrStdout(), "Reading from stdin:\n")
   348  			}
   349  			r = os.Stdin
   350  			readers = append(readers, local.ReaderSource{Name: f, Reader: r})
   351  			continue
   352  		}
   353  
   354  		fi, err := os.Stat(f)
   355  		if err != nil {
   356  			return nil, err
   357  		}
   358  
   359  		if fi.IsDir() {
   360  			dirReaders, err := gatherFilesInDirectory(cmd, f)
   361  			if err != nil {
   362  				return nil, err
   363  			}
   364  			readers = append(readers, dirReaders...)
   365  		} else {
   366  			if !isValidFile(f) {
   367  				fmt.Fprintf(cmd.ErrOrStderr(), "Skipping file %v, recognized file extensions are: %v\n", f, fileExtensions)
   368  				continue
   369  			}
   370  			rs, err := gatherFile(f)
   371  			if err != nil {
   372  				return nil, err
   373  			}
   374  			readers = append(readers, rs)
   375  		}
   376  	}
   377  	return readers, nil
   378  }
   379  
   380  func gatherFile(f string) (local.ReaderSource, error) {
   381  	r, err := os.Open(f)
   382  	if err != nil {
   383  		return local.ReaderSource{}, err
   384  	}
   385  	runtime.SetFinalizer(r, func(x *os.File) {
   386  		err = x.Close()
   387  		if err != nil {
   388  			log.Infof("file : %s is not closed: %v", f, err)
   389  		}
   390  	})
   391  	return local.ReaderSource{Name: f, Reader: r}, nil
   392  }
   393  
   394  func gatherFilesInDirectory(cmd *cobra.Command, dir string) ([]local.ReaderSource, error) {
   395  	var readers []local.ReaderSource
   396  
   397  	err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
   398  		if err != nil {
   399  			return err
   400  		}
   401  		// If we encounter a directory, recurse only if the --recursive option
   402  		// was provided and the directory is not the same as dir.
   403  		if info.IsDir() {
   404  			if !recursive && dir != path {
   405  				return filepath.SkipDir
   406  			}
   407  			return nil
   408  		}
   409  
   410  		if !isValidFile(path) {
   411  			fmt.Fprintf(cmd.ErrOrStderr(), "Skipping file %v, recognized file extensions are: %v\n", path, fileExtensions)
   412  			return nil
   413  		}
   414  
   415  		r, err := os.Open(path)
   416  		if err != nil {
   417  			return err
   418  		}
   419  		runtime.SetFinalizer(r, func(x *os.File) {
   420  			err = x.Close()
   421  			if err != nil {
   422  				log.Infof("file: %s is not closed: %v", path, err)
   423  			}
   424  		})
   425  		readers = append(readers, local.ReaderSource{Name: path, Reader: r})
   426  		return nil
   427  	})
   428  	return readers, err
   429  }
   430  
   431  func errorIfMessagesExceedThreshold(messages []diag.Message) error {
   432  	foundIssues := false
   433  	for _, m := range messages {
   434  		if m.Type.Level().IsWorseThanOrEqualTo(failureThreshold.Level) {
   435  			foundIssues = true
   436  		}
   437  	}
   438  
   439  	if foundIssues {
   440  		return AnalyzerFoundIssuesError{}
   441  	}
   442  
   443  	return nil
   444  }
   445  
   446  func isValidFile(f string) bool {
   447  	ext := filepath.Ext(f)
   448  	for _, e := range fileExtensions {
   449  		if e == ext {
   450  			return true
   451  		}
   452  	}
   453  	return false
   454  }
   455  
   456  func AnalyzersAsString(analyzers []analysis.Analyzer) string {
   457  	nameToAnalyzer := make(map[string]analysis.Analyzer)
   458  	analyzerNames := make([]string, len(analyzers))
   459  	for i, a := range analyzers {
   460  		analyzerNames[i] = a.Metadata().Name
   461  		nameToAnalyzer[a.Metadata().Name] = a
   462  	}
   463  	sort.Strings(analyzerNames)
   464  
   465  	var b strings.Builder
   466  	for _, aName := range analyzerNames {
   467  		b.WriteString(fmt.Sprintf("* %s:\n", aName))
   468  		a := nameToAnalyzer[aName]
   469  		if a.Metadata().Description != "" {
   470  			b.WriteString(fmt.Sprintf("    %s\n", a.Metadata().Description))
   471  		}
   472  	}
   473  	return b.String()
   474  }
   475  
   476  func analyzeTargetAsString() string {
   477  	if allNamespaces {
   478  		return "all namespaces"
   479  	}
   480  	return fmt.Sprintf("namespace: %s", selectedNamespace)
   481  }
   482  
   483  // TODO: Refactor output writer so that it is smart enough to know when to output what.
   484  func isJSONorYAMLOutputFormat() bool {
   485  	return msgOutputFormat == formatting.JSONFormat || msgOutputFormat == formatting.YAMLFormat
   486  }
   487  
   488  type Client struct {
   489  	client kube.Client
   490  	remote bool
   491  }
   492  
   493  func getClients(ctx cli.Context) ([]*Client, error) {
   494  	client, err := ctx.CLIClient()
   495  	if err != nil {
   496  		return nil, err
   497  	}
   498  	clients := []*Client{
   499  		{
   500  			client: client,
   501  			remote: false,
   502  		},
   503  	}
   504  	secrets, err := client.Kube().CoreV1().Secrets(ctx.IstioNamespace()).List(context.Background(), metav1.ListOptions{
   505  		LabelSelector: fmt.Sprintf("%s=%s", multicluster.MultiClusterSecretLabel, "true"),
   506  	})
   507  	if err != nil {
   508  		return nil, err
   509  	}
   510  	for _, s := range secrets.Items {
   511  		for _, cfg := range s.Data {
   512  			clientConfig, err := clientcmd.NewClientConfigFromBytes(cfg)
   513  			if err != nil {
   514  				return nil, err
   515  			}
   516  			rawConfig, err := clientConfig.RawConfig()
   517  			if err != nil {
   518  				return nil, err
   519  			}
   520  			curContext := rawConfig.Contexts[rawConfig.CurrentContext]
   521  			if curContext == nil {
   522  				continue
   523  			}
   524  			client, err := kube.NewCLIClient(clientConfig,
   525  				kube.WithRevision(revisionSpecified),
   526  				kube.WithCluster(cluster.ID(curContext.Cluster)))
   527  			if err != nil {
   528  				return nil, err
   529  			}
   530  			clients = append(clients, &Client{
   531  				client: client,
   532  				remote: true,
   533  			})
   534  		}
   535  	}
   536  	return clients, nil
   537  }