diff --git a/README.md b/README.md index 0978047..3b3cda0 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,6 @@ # Marvin +[![Go Reference](https://pkg.go.dev/badge/github.com/undistro/marvin.svg)](https://pkg.go.dev/github.com/undistro/marvin) [![Test](https://github.com/undistro/marvin/actions/workflows/test.yml/badge.svg?branch=main&event=push)](https://github.com/undistro/marvin/actions/workflows/test.yml) ![GitHub release (latest SemVer)](https://img.shields.io/github/v/release/undistro/marvin?sort=semver) ![GitHub](https://img.shields.io/github/license/undistro/marvin) diff --git a/cmd/root.go b/cmd/root.go index bd4c6dd..cd35109 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -15,11 +15,19 @@ package cmd import ( + "context" + "flag" "os" + "github.com/fatih/color" + "github.com/go-logr/logr" "github.com/spf13/cobra" + "k8s.io/klog/v2" + "k8s.io/klog/v2/klogr" ) +var noColor bool + // rootCmd represents the base command when called without any subcommands var rootCmd = &cobra.Command{ Use: "marvin", @@ -32,3 +40,21 @@ func Execute() { os.Exit(1) } } + +func init() { + cobra.OnInitialize(initNoColor) + + rootCmd.PersistentFlags().BoolVar(&noColor, "no-color", false, "Disable color output") + var allFlags flag.FlagSet + klog.InitFlags(&allFlags) + allFlags.VisitAll(func(f *flag.Flag) { + if f.Name == "v" { + rootCmd.PersistentFlags().AddGoFlag(f) + } + }) + rootCmd.SetContext(logr.NewContext(context.Background(), klogr.New())) +} + +func initNoColor() { + color.NoColor = noColor +} diff --git a/cmd/scan.go b/cmd/scan.go index 8901991..297ddee 100644 --- a/cmd/scan.go +++ b/cmd/scan.go @@ -15,6 +15,8 @@ package cmd import ( + "os" + "github.com/spf13/cobra" "github.com/undistro/marvin/pkg/cmd" @@ -27,16 +29,17 @@ var ( scanCmd = &cobra.Command{ Use: "scan [flags]", Short: "Scan a Kubernetes cluster", - PreRunE: func(c *cobra.Command, args []string) error { - return scanOptions.Validate() - }, RunE: func(c *cobra.Command, args []string) error { - if err := scanOptions.Init(); err != nil { + if err := scanOptions.Init(c.Context()); err != nil { return err } - if err := scanOptions.Run(); err != nil { + hasError, err := scanOptions.Run() + if err != nil { return err } + if hasError && !*scanOptions.NoFail { + os.Exit(2) + } return nil }, } diff --git a/go.mod b/go.mod index 5b7512f..4c5cce6 100644 --- a/go.mod +++ b/go.mod @@ -5,16 +5,18 @@ go 1.20 require ( github.com/Masterminds/semver/v3 v3.2.0 github.com/fatih/color v1.14.1 + github.com/go-logr/logr v1.2.3 github.com/google/cel-go v0.13.0 github.com/olekukonko/tablewriter v0.0.5 github.com/spf13/cobra v1.6.1 github.com/spf13/pflag v1.0.5 github.com/stretchr/testify v1.8.2 - k8s.io/api v0.26.2 - k8s.io/apimachinery v0.26.2 - k8s.io/apiserver v0.26.2 - k8s.io/cli-runtime v0.26.2 - k8s.io/client-go v0.26.2 + k8s.io/api v0.26.3 + k8s.io/apimachinery v0.26.3 + k8s.io/apiserver v0.26.3 + k8s.io/cli-runtime v0.26.3 + k8s.io/client-go v0.26.3 + k8s.io/klog/v2 v2.90.1 k8s.io/utils v0.0.0-20221107191617-1a15be271d1d sigs.k8s.io/yaml v1.3.0 ) @@ -25,7 +27,6 @@ require ( github.com/emicklei/go-restful/v3 v3.9.0 // indirect github.com/evanphx/json-patch v4.12.0+incompatible // indirect github.com/go-errors/errors v1.0.1 // indirect - github.com/go-logr/logr v1.2.3 // indirect github.com/go-openapi/jsonpointer v0.19.5 // indirect github.com/go-openapi/jsonreference v0.20.0 // indirect github.com/go-openapi/swag v0.19.14 // indirect @@ -69,7 +70,6 @@ require ( gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect - k8s.io/klog/v2 v2.80.1 // indirect k8s.io/kube-openapi v0.0.0-20221012153701-172d655c2280 // indirect sigs.k8s.io/json v0.0.0-20220713155537-f223a00ba0e2 // indirect sigs.k8s.io/kustomize/api v0.12.1 // indirect diff --git a/go.sum b/go.sum index f286ad5..99d420d 100644 --- a/go.sum +++ b/go.sum @@ -248,18 +248,18 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -k8s.io/api v0.26.2 h1:dM3cinp3PGB6asOySalOZxEG4CZ0IAdJsrYZXE/ovGQ= -k8s.io/api v0.26.2/go.mod h1:1kjMQsFE+QHPfskEcVNgL3+Hp88B80uj0QtSOlj8itU= -k8s.io/apimachinery v0.26.2 h1:da1u3D5wfR5u2RpLhE/ZtZS2P7QvDgLZTi9wrNZl/tQ= -k8s.io/apimachinery v0.26.2/go.mod h1:ats7nN1LExKHvJ9TmwootT00Yz05MuYqPXEXaVeOy5I= -k8s.io/apiserver v0.26.2 h1:Pk8lmX4G14hYqJd1poHGC08G03nIHVqdJMR0SD3IH3o= -k8s.io/apiserver v0.26.2/go.mod h1:GHcozwXgXsPuOJ28EnQ/jXEM9QeG6HT22YxSNmpYNh8= -k8s.io/cli-runtime v0.26.2 h1:6XcIQOYW1RGNwFgRwejvyUyAojhToPmJLGr0JBMC5jw= -k8s.io/cli-runtime v0.26.2/go.mod h1:U7sIXX7n6ZB+MmYQsyJratzPeJwgITqrSlpr1a5wM5I= -k8s.io/client-go v0.26.2 h1:s1WkVujHX3kTp4Zn4yGNFK+dlDXy1bAAkIl+cFAiuYI= -k8s.io/client-go v0.26.2/go.mod h1:u5EjOuSyBa09yqqyY7m3abZeovO/7D/WehVVlZ2qcqU= -k8s.io/klog/v2 v2.80.1 h1:atnLQ121W371wYYFawwYx1aEY2eUfs4l3J72wtgAwV4= -k8s.io/klog/v2 v2.80.1/go.mod h1:y1WjHnz7Dj687irZUWR/WLkLc5N1YHtjLdmgWjndZn0= +k8s.io/api v0.26.3 h1:emf74GIQMTik01Aum9dPP0gAypL8JTLl/lHa4V9RFSU= +k8s.io/api v0.26.3/go.mod h1:PXsqwPMXBSBcL1lJ9CYDKy7kIReUydukS5JiRlxC3qE= +k8s.io/apimachinery v0.26.3 h1:dQx6PNETJ7nODU3XPtrwkfuubs6w7sX0M8n61zHIV/k= +k8s.io/apimachinery v0.26.3/go.mod h1:ats7nN1LExKHvJ9TmwootT00Yz05MuYqPXEXaVeOy5I= +k8s.io/apiserver v0.26.3 h1:blBpv+yOiozkPH2aqClhJmJY+rp53Tgfac4SKPDJnU4= +k8s.io/apiserver v0.26.3/go.mod h1:CJe/VoQNcXdhm67EvaVjYXxR3QyfwpceKPuPaeLibTA= +k8s.io/cli-runtime v0.26.3 h1:3ULe0oI28xmgeLMVXIstB+ZL5CTGvWSMVMLeHxitIuc= +k8s.io/cli-runtime v0.26.3/go.mod h1:5YEhXLV4kLt/OSy9yQwtSSNZU2Z7aTEYta1A+Jg4VC4= +k8s.io/client-go v0.26.3 h1:k1UY+KXfkxV2ScEL3gilKcF7761xkYsSD6BC9szIu8s= +k8s.io/client-go v0.26.3/go.mod h1:ZPNu9lm8/dbRIPAgteN30RSXea6vrCpFvq+MateTUuQ= +k8s.io/klog/v2 v2.90.1 h1:m4bYOKall2MmOiRaR1J+We67Do7vm9KiQVlT96lnHUw= +k8s.io/klog/v2 v2.90.1/go.mod h1:y1WjHnz7Dj687irZUWR/WLkLc5N1YHtjLdmgWjndZn0= k8s.io/kube-openapi v0.0.0-20221012153701-172d655c2280 h1:+70TFaan3hfJzs+7VK2o+OGxg8HsuBr/5f6tVAjDu6E= k8s.io/kube-openapi v0.0.0-20221012153701-172d655c2280/go.mod h1:+Axhij7bCpeqhklhUTe3xmOn6bWxolyZEeyaFpjGtl4= k8s.io/utils v0.0.0-20221107191617-1a15be271d1d h1:0Smp/HP1OH4Rvhe+4B8nWGERtlqAGSftbSbbmm45oFs= diff --git a/pkg/cmd/scan.go b/pkg/cmd/scan.go index 85cb2b0..6fe10db 100644 --- a/pkg/cmd/scan.go +++ b/pkg/cmd/scan.go @@ -22,6 +22,7 @@ import ( "strings" "github.com/fatih/color" + "github.com/go-logr/logr" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/version" @@ -45,15 +46,18 @@ type ScanOptions struct { ChecksPath *string DisableBuiltIn *bool OutputFormat *string - NoColor *bool + NoFail *bool SkipAnnotation *string DisableAnnotationSkip *bool + ctx context.Context + log logr.Logger printer printers.Printer client *dynamic.DynamicClient kubeVersion *version.Info apiResources []*metav1.APIResourceList resources map[string][]unstructured.Unstructured + gvrs map[string]string } func NewScanOptions() *ScanOptions { @@ -63,7 +67,7 @@ func NewScanOptions() *ScanOptions { ChecksPath: pointer.String(""), DisableBuiltIn: pointer.Bool(false), OutputFormat: pointer.String("table"), - NoColor: pointer.Bool(false), + NoFail: pointer.Bool(false), DisableAnnotationSkip: pointer.Bool(false), SkipAnnotation: pointer.String("marvin.undistro.io/skip"), } @@ -81,8 +85,8 @@ func (o *ScanOptions) AddFlags(flags *pflag.FlagSet) { if o.OutputFormat != nil { flags.StringVarP(o.OutputFormat, "output", "o", *o.OutputFormat, `Output format. One of: ("table", "json", "yaml" or "markdown")`) } - if o.NoColor != nil { - flags.BoolVar(o.NoColor, "no-color", *o.NoColor, "Disable color output") + if o.NoFail != nil { + flags.BoolVar(o.NoFail, "no-fail", *o.NoFail, "Return an exit code of zero even if there are errors in the report") } if o.SkipAnnotation != nil { flags.StringVar(o.SkipAnnotation, "skip-annotation", *o.SkipAnnotation, "Annotation name for skipping checks") @@ -92,25 +96,13 @@ func (o *ScanOptions) AddFlags(flags *pflag.FlagSet) { } } -func (o *ScanOptions) ToDynamicClient() (*dynamic.DynamicClient, error) { - config, err := o.ToRESTConfig() - if err != nil { - return nil, err - } - return dynamic.NewForConfig(config) -} - -// Validate ensures that all required arguments and flag values are provided -func (o *ScanOptions) Validate() error { - if *o.DisableBuiltIn == true && *o.ChecksPath == "" { - return errors.New(`please set '--checks/-f' or keep 'disable-builtin' 'false'`) - } - return nil -} - // Init initializes the kubernetes clients, get server version and API resources -func (o *ScanOptions) Init() error { - color.NoColor = *o.NoColor +func (o *ScanOptions) Init(ctx context.Context) error { + if err := o.Validate(); err != nil { + return err + } + o.ctx = ctx + o.log = logr.FromContextOrDiscard(o.ctx) var printer printers.Printer switch *o.OutputFormat { @@ -149,48 +141,106 @@ func (o *ScanOptions) Init() error { o.apiResources = apiResources o.printer = printer o.resources = make(map[string][]unstructured.Unstructured) + o.gvrs = make(map[string]string) return nil } -func (o *ScanOptions) Run() error { +// Validate ensures that all required arguments and flag values are provided +func (o *ScanOptions) Validate() error { + if *o.DisableBuiltIn == true && *o.ChecksPath == "" { + return errors.New(`please set '--checks/-f' or keep 'disable-builtin' 'false'`) + } + return nil +} + +// ToDynamicClient returns a DynamicClient using a computed RESTConfig. +func (o *ScanOptions) ToDynamicClient() (*dynamic.DynamicClient, error) { + config, err := o.ToRESTConfig() + if err != nil { + return nil, err + } + return dynamic.NewForConfig(config) +} + +// Run executes the scan command +func (o *ScanOptions) Run() (bool, error) { allChecks, err := o.getChecks() if err != nil { - return err + return false, err } report := types.NewReport(o.kubeVersion) for _, check := range allChecks { - cr := types.NewCheckResult(check) + cr := o.runCheck(check) report.Add(cr) - v, err := validator.Compile(check, o.apiResources, o.kubeVersion) + } + + report.GVRs = o.gvrs + hasError := report.HasError() + if hasError { + o.log.Info("scan finished with errors") + } + return hasError, o.printer.PrintObj(*report, o.Out) +} + +// getChecks returns a list of checks.Check based on the flags, including built-in checks or/and from a path. +func (o *ScanOptions) getChecks() ([]types.Check, error) { + o.log.V(3).Info("loading checks", "builtin", !*o.DisableBuiltIn, "custom", *o.ChecksPath != "") + var allChecks []types.Check + if !*o.DisableBuiltIn { + allChecks = loader.Builtins + o.log.V(3).Info("builtin checks loaded", "total", len(loader.Builtins)) + } + if *o.ChecksPath != "" { + localChecks, err := loader.LoadChecks(*o.ChecksPath) if err != nil { - cr.AddError(fmt.Errorf("compile error: %s", err.Error())) - continue + o.log.Error(err, "load checks error", "path", *o.ChecksPath) + return nil, fmt.Errorf("load checks error: %s", err.Error()) } - resources, errs := o.loadResources(check) - cr.AddErrors(errs...) - for gvr, objs := range resources { - for _, obj := range objs { - report.AddGVR(obj, gvr) - if o.IsSkipped(check.ID, obj.GetAnnotations()) { - cr.AddSkipped(obj) - continue - } - passed, _, err := v.Validate(obj, check.Params) - if err != nil { - cr.AddError(fmt.Errorf("%s validate error: %s", check.Path, err.Error())) - continue - } - if passed { - cr.AddPassed(obj) - } else { - cr.AddFailed(obj) - } + o.log.V(2).Info("custom checks loaded", "total", len(localChecks)) + allChecks = append(allChecks, localChecks...) + } + return allChecks, nil +} + +// runCheck performs a check +func (o *ScanOptions) runCheck(check types.Check) *types.CheckResult { + log := o.log.WithValues("check", check.ID) + cr := types.NewCheckResult(check) + defer cr.UpdateStatus() + v, err := validator.Compile(check, o.apiResources, o.kubeVersion) + if err != nil { + log.Error(err, "compile error") + cr.AddError(fmt.Errorf("%s compile error: %s", check.Path, err.Error())) + return cr + } + log.V(3).Info("check compiled successfully") + resources, errs := o.loadResources(check) + cr.AddErrors(errs...) + for gvr, objs := range resources { + for _, obj := range objs { + log := log.WithValues("obj", fmt.Sprintf("%s/%s", types.GVK(obj), types.NamespacedName(obj))) + o.addGVR(obj, gvr) + if o.isSkipped(check.ID, obj.GetAnnotations()) { + log.V(4).Info("skipped") + cr.AddSkipped(obj) + continue + } + passed, _, err := v.Validate(obj, check.Params) + if err != nil { + log.Error(err, "validate error") + cr.AddError(fmt.Errorf("%s validate error: %s", check.Path, err.Error())) + continue + } + if passed { + log.V(4).Info("passed") + cr.AddPassed(obj) + } else { + log.V(4).Info("failed") + cr.AddFailed(obj) } } - cr.UpdateStatus() } - - return o.printer.PrintObj(*report, o.Out) + return cr } // loadResources returns a map of resource slice by GVR to be validated by the given check @@ -200,15 +250,20 @@ func (o *ScanOptions) loadResources(check types.Check) (map[string][]unstructure for _, r := range check.Match.Resources { gvr := r.ToGVR() gvrs := fmt.Sprintf("%s/%s", gvr.GroupVersion().String(), gvr.Resource) + log := o.log.WithValues("check", check.ID) objs, cached := o.resources[gvrs] if cached { + log.V(3).Info(gvrs+" resources cached", "total", len(objs)) resources[gvrs] = objs } else { - ul, err := o.client.Resource(gvr).Namespace(*o.Namespace).List(context.Background(), metav1.ListOptions{}) + log.V(3).Info(fmt.Sprintf("listing %s from kubernetes", gvrs)) + ul, err := o.client.Resource(gvr).Namespace(*o.Namespace).List(o.ctx, metav1.ListOptions{}) if err != nil { + log.Error(err, "failed to list "+gvrs) errs = append(errs, fmt.Errorf("list %s error: %s", gvr.Resource, err.Error())) continue } + log.V(1).Info(gvrs+" loaded from kubernetes", "total", len(ul.Items)) o.resources[gvrs] = ul.Items resources[gvrs] = ul.Items } @@ -216,23 +271,8 @@ func (o *ScanOptions) loadResources(check types.Check) (map[string][]unstructure return resources, errs } -// getChecks returns a list of checks.Check based on the flags, including built-in checks or/and from a path. -func (o *ScanOptions) getChecks() ([]types.Check, error) { - var allChecks []types.Check - if !*o.DisableBuiltIn { - allChecks = loader.Builtins - } - if *o.ChecksPath != "" { - localChecks, err := loader.LoadChecks(*o.ChecksPath) - if err != nil { - return nil, fmt.Errorf("load checks error: %s", err.Error()) - } - allChecks = append(allChecks, localChecks...) - } - return allChecks, nil -} - -func (o *ScanOptions) IsSkipped(checkID string, annotations map[string]string) bool { +// isSkipped returns true if the checkID is annotated to be skipped +func (o *ScanOptions) isSkipped(checkID string, annotations map[string]string) bool { if annotations == nil { return false } @@ -251,3 +291,11 @@ func (o *ScanOptions) IsSkipped(checkID string, annotations map[string]string) b } return false } + +// addGVR updates the map of GVR by GVK +func (o *ScanOptions) addGVR(obj unstructured.Unstructured, gvr string) { + gvk := types.GVK(obj) + if _, ok := o.gvrs[gvk]; !ok { + o.gvrs[gvk] = gvr + } +} diff --git a/pkg/loader/builtin_test.go b/pkg/loader/builtin_test.go index 999a8ad..036cd49 100644 --- a/pkg/loader/builtin_test.go +++ b/pkg/loader/builtin_test.go @@ -23,4 +23,5 @@ import ( func TestBuiltins(t *testing.T) { assert.NotNil(t, Builtins) assert.Greater(t, len(Builtins), 0) + assert.Equal(t, len(Builtins), 22) } diff --git a/pkg/printers/table.go b/pkg/printers/table.go index 886ed59..b0485d6 100644 --- a/pkg/printers/table.go +++ b/pkg/printers/table.go @@ -18,6 +18,7 @@ import ( "io" "sort" "strconv" + "strings" "github.com/fatih/color" "github.com/olekukonko/tablewriter" @@ -101,7 +102,7 @@ func colorStatus(s types.CheckStatus) string { case types.StatusFailed: return red("%s", s) case types.StatusError: - return redBold("%s", s) + return redBold("%s", strings.ToUpper(s.String())) default: return s.String() } diff --git a/pkg/types/report.go b/pkg/types/report.go index fdbfe2c..6c31b85 100644 --- a/pkg/types/report.go +++ b/pkg/types/report.go @@ -24,7 +24,7 @@ import ( type Report struct { KubeVersion *version.Info `json:"kubeVersion"` Checks []*CheckResult `json:"checks"` - GVRs map[string]string `json:"gvrs"` + GVRs map[string]string `json:"gvrs,omitempty"` } func NewReport(kubeVersion *version.Info) *Report { @@ -35,14 +35,13 @@ func (r *Report) Add(cr *CheckResult) { r.Checks = append(r.Checks, cr) } -func (r *Report) AddGVR(obj unstructured.Unstructured, gvr string) { - if r.GVRs == nil { - r.GVRs = map[string]string{} - } - gvk := GVK(obj) - if _, ok := r.GVRs[gvk]; !ok { - r.GVRs[gvk] = gvr +func (r *Report) HasError() bool { + for _, check := range r.Checks { + if len(check.Errors) > 0 { + return true + } } + return false } type CheckResult struct {