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 }