From 649c2ca4d60ed558aa70a2bf96fc8fa9597dcc25 Mon Sep 17 00:00:00 2001 From: Yann Hamon Date: Sat, 14 Nov 2020 15:54:45 +0100 Subject: [PATCH 1/2] refactor validator pkg so it can be usable in a third party app --- cmd/kubeconform/main.go | 112 +++----------------- pkg/validator/validator.go | 179 ++++++++++++++++++++++++++------ pkg/validator/validator_test.go | 49 +++++++-- 3 files changed, 198 insertions(+), 142 deletions(-) diff --git a/cmd/kubeconform/main.go b/cmd/kubeconform/main.go index 6c2d7e1..fb2ccdb 100644 --- a/cmd/kubeconform/main.go +++ b/cmd/kubeconform/main.go @@ -3,94 +3,15 @@ package main import ( "context" "fmt" - "github.com/xeipuuv/gojsonschema" "os" "sync" - "github.com/yannh/kubeconform/pkg/cache" "github.com/yannh/kubeconform/pkg/config" "github.com/yannh/kubeconform/pkg/output" - "github.com/yannh/kubeconform/pkg/registry" "github.com/yannh/kubeconform/pkg/resource" "github.com/yannh/kubeconform/pkg/validator" ) -func downloadSchema(registries []registry.Registry, kind, version, k8sVersion string) (*gojsonschema.Schema, error) { - var err error - var schemaBytes []byte - - for _, reg := range registries { - schemaBytes, err = reg.DownloadSchema(kind, version, k8sVersion) - if err == nil { - return gojsonschema.NewSchema(gojsonschema.NewBytesLoader(schemaBytes)) - } - - // If we get a 404, we try the next registry, but we exit if we get a real failure - if _, notfound := err.(*registry.NotFoundError); notfound { - continue - } - - return nil, err - } - - return nil, nil // No schema found - we don't consider it an error, resource will be skipped -} - -func ValidateResources(resources <-chan resource.Resource, validationResults chan<- validator.Result, regs []registry.Registry, k8sVersion string, c *cache.SchemaCache, skip func(signature resource.Signature) bool, reject func(signature resource.Signature) bool, ignoreMissingSchemas bool) { - for res := range resources { - sig, err := res.Signature() - if err != nil { - validationResults <- validator.Result{Resource: res, Err: fmt.Errorf("error while parsing: %s", err), Status: validator.Error} - continue - } - - if sig.Kind == "" { - validationResults <- validator.Result{Resource: res, Err: nil, Status: validator.Empty} - continue // We skip resoures that don't have a Kind defined - } - - if skip(*sig) { - validationResults <- validator.Result{Resource: res, Err: nil, Status: validator.Skipped} - continue - } - - if reject(*sig) { - validationResults <- validator.Result{Resource: res, Err: fmt.Errorf("prohibited resource kind %s", sig.Kind), Status: validator.Error} - continue - } - - cached := false - var schema *gojsonschema.Schema - cacheKey := "" - - if c != nil { - cacheKey = cache.Key(sig.Kind, sig.Version, k8sVersion) - schema, cached = c.Get(cacheKey) - } - - if !cached { - if schema, err = downloadSchema(regs, sig.Kind, sig.Version, k8sVersion); err != nil { - validationResults <- validator.Result{Resource: res, Err: err, Status: validator.Error} - continue - } - - if c != nil { - c.Set(cacheKey, schema) - } - } - - if schema == nil { - if ignoreMissingSchemas { - validationResults <- validator.Result{Resource: res, Err: nil, Status: validator.Skipped} - } else { - validationResults <- validator.Result{Resource: res, Err: fmt.Errorf("could not find schema for %s", sig.Kind), Status: validator.Error} - } - } - - validationResults <- validator.Validate(res, schema) - } -} - func processResults(ctx context.Context, o output.Output, validationResults <-chan validator.Result, exitOnError bool) <-chan bool { success := true result := make(chan bool) @@ -138,27 +59,21 @@ func realMain() int { isStdin = true } - filter := func(signature resource.Signature) bool { - isSkipKind, ok := cfg.SkipKinds[signature.Kind] - return ok && isSkipKind - } - - reject := func(signature resource.Signature) bool { - _, ok := cfg.RejectKinds[signature.Kind] - return ok - } - - registries := []registry.Registry{} - for _, schemaLocation := range cfg.SchemaLocations { - registries = append(registries, registry.New(schemaLocation, cfg.Strict, cfg.SkipTLS)) - } - var o output.Output if o, err = output.New(cfg.OutputFormat, cfg.Summary, isStdin, cfg.Verbose); err != nil { fmt.Fprintln(os.Stderr, err) return 1 } + v := validator.New(cfg.SchemaLocations, &validator.Opts{ + SkipTLS: cfg.SkipTLS, + SkipKinds: cfg.SkipKinds, + RejectKinds: cfg.RejectKinds, + KubernetesVersion: cfg.KubernetesVersion, + Strict: cfg.Strict, + IgnoreMissingSchemas: cfg.IgnoreMissingSchemas, + }) + var resourcesChan <-chan resource.Resource var errors <-chan error validationResults := make(chan validator.Result) @@ -172,14 +87,15 @@ func realMain() int { resourcesChan, errors = resource.FromFiles(ctx, cfg.IgnoreFilenamePatterns, cfg.Files...) } - c := cache.New() wg := sync.WaitGroup{} for i := 0; i < cfg.NumberOfWorkers; i++ { wg.Add(1) - go func() { - ValidateResources(resourcesChan, validationResults, registries, cfg.KubernetesVersion, c, filter, reject, cfg.IgnoreMissingSchemas) + go func(resources <-chan resource.Resource, validationResults chan<- validator.Result, v *validator.Validator) { + for res := range resources { + validationResults <- v.Validate(res) + } wg.Done() - }() + }(resourcesChan, validationResults, v) } wg.Add(1) diff --git a/pkg/validator/validator.go b/pkg/validator/validator.go index c2ac8fb..6cc1914 100644 --- a/pkg/validator/validator.go +++ b/pkg/validator/validator.go @@ -2,6 +2,8 @@ package validator import ( "fmt" + "github.com/yannh/kubeconform/pkg/cache" + "github.com/yannh/kubeconform/pkg/registry" "github.com/yannh/kubeconform/pkg/resource" "github.com/xeipuuv/gojsonschema" @@ -19,6 +21,150 @@ const ( Empty ) +type Validator struct { + opts *Opts + schemaCache *cache.SchemaCache + schemaDownload func(registries []registry.Registry, kind, version, k8sVersion string) (*gojsonschema.Schema, error) + regs []registry.Registry +} + +type Opts struct { + SkipTLS bool + SkipKinds map[string]bool + RejectKinds map[string]bool + KubernetesVersion string + Strict bool + IgnoreMissingSchemas bool +} + +func downloadSchema(registries []registry.Registry, kind, version, k8sVersion string) (*gojsonschema.Schema, error) { + var err error + var schemaBytes []byte + + for _, reg := range registries { + schemaBytes, err = reg.DownloadSchema(kind, version, k8sVersion) + if err == nil { + return gojsonschema.NewSchema(gojsonschema.NewBytesLoader(schemaBytes)) + } + + // If we get a 404, we try the next registry, but we exit if we get a real failure + if _, notfound := err.(*registry.NotFoundError); notfound { + continue + } + + return nil, err + } + + return nil, nil // No schema found - we don't consider it an error, resource will be skipped +} + +func New(schemaLocations []string, opts *Opts) *Validator { + registries := []registry.Registry{} + for _, schemaLocation := range schemaLocations { + registries = append(registries, registry.New(schemaLocation, opts.Strict, opts.SkipTLS)) + } + + if opts.SkipKinds == nil { + opts.SkipKinds = map[string]bool{} + } + if opts.RejectKinds == nil { + opts.RejectKinds = map[string]bool{} + } + + return &Validator{ + opts: opts, + schemaDownload: downloadSchema, + schemaCache: cache.New(), + regs: registries, + } +} + +func (v *Validator) Validate(res resource.Resource) Result { + skip := func(signature resource.Signature) bool { + isSkipKind, ok := v.opts.SkipKinds[signature.Kind] + return ok && isSkipKind + } + + reject := func(signature resource.Signature) bool { + _, ok := v.opts.RejectKinds[signature.Kind] + return ok + } + + sig, err := res.Signature() + if err != nil { + return Result{Resource: res, Err: fmt.Errorf("error while parsing: %s", err), Status: Error} + } + + if sig.Kind == "" { + return Result{Resource: res, Err: nil, Status: Empty} + } + + if skip(*sig) { + return Result{Resource: res, Err: nil, Status: Skipped} + } + + if reject(*sig) { + return Result{Resource: res, Err: fmt.Errorf("prohibited resource kind %s", sig.Kind), Status: Error} + } + + cached := false + var schema *gojsonschema.Schema + cacheKey := "" + + if v.schemaCache != nil { + cacheKey = cache.Key(sig.Kind, sig.Version, v.opts.KubernetesVersion) + schema, cached = v.schemaCache.Get(cacheKey) + } + + if !cached { + if schema, err = v.schemaDownload(v.regs, sig.Kind, sig.Version, v.opts.KubernetesVersion); err != nil { + return Result{Resource: res, Err: err, Status: Error} + } + + if v.schemaCache != nil { + v.schemaCache.Set(cacheKey, schema) + } + } + + if schema == nil { + if v.opts.IgnoreMissingSchemas { + return Result{Resource: res, Err: nil, Status: Skipped} + } else { + return Result{Resource: res, Err: fmt.Errorf("could not find schema for %s", sig.Kind), Status: Error} + } + } + + if schema == nil { + return Result{Resource: res, Status: Skipped, Err: nil} + } + + var resource map[string]interface{} + if err := yaml.Unmarshal(res.Bytes, &resource); err != nil { + return Result{Resource: res, Status: Error, Err: fmt.Errorf("error unmarshalling resource: %s", err)} + } + resourceLoader := gojsonschema.NewGoLoader(resource) + + results, err := schema.Validate(resourceLoader) + if err != nil { + // This error can only happen if the Object to validate is poorly formed. There's no hope of saving this one + return Result{Resource: res, Status: Error, Err: fmt.Errorf("problem validating schema. Check JSON formatting: %s", err)} + } + + if results.Valid() { + return Result{Resource: res, Status: Valid} + } + + msg := "" + for _, errMsg := range results.Errors() { + if msg != "" { + msg += " - " + } + msg += errMsg.Description() + } + + return Result{Resource: res, Status: Invalid, Err: fmt.Errorf("%s", msg)} +} + // ValidFormat is a type for quickly forcing // new formats on the gojsonschema loader type ValidFormat struct{} @@ -52,36 +198,3 @@ func NewError(filename string, err error) Result { Status: Error, } } - -// Validate validates a single Kubernetes resource against a Json Schema -func Validate(res resource.Resource, schema *gojsonschema.Schema) Result { - if schema == nil { - return Result{Resource: res, Status: Skipped, Err: nil} - } - - var resource map[string]interface{} - if err := yaml.Unmarshal(res.Bytes, &resource); err != nil { - return Result{Resource: res, Status: Error, Err: fmt.Errorf("error unmarshalling resource: %s", err)} - } - resourceLoader := gojsonschema.NewGoLoader(resource) - - results, err := schema.Validate(resourceLoader) - if err != nil { - // This error can only happen if the Object to validate is poorly formed. There's no hope of saving this one - return Result{Resource: res, Status: Error, Err: fmt.Errorf("problem validating schema. Check JSON formatting: %s", err)} - } - - if results.Valid() { - return Result{Resource: res, Status: Valid} - } - - msg := "" - for _, errMsg := range results.Errors() { - if msg != "" { - msg += " - " - } - msg += errMsg.Description() - } - - return Result{Resource: res, Status: Invalid, Err: fmt.Errorf("%s", msg)} -} diff --git a/pkg/validator/validator_test.go b/pkg/validator/validator_test.go index c9f2529..b7259b3 100644 --- a/pkg/validator/validator_test.go +++ b/pkg/validator/validator_test.go @@ -1,7 +1,7 @@ package validator import ( - "fmt" + "github.com/yannh/kubeconform/pkg/registry" "github.com/yannh/kubeconform/pkg/resource" "testing" @@ -12,11 +12,12 @@ func TestValidate(t *testing.T) { for i, testCase := range []struct { name string rawResource, schema []byte - expect error + expect Status }{ { "valid resource", []byte(` +Kind: name firstName: foo lastName: bar `), @@ -24,6 +25,9 @@ lastName: bar "title": "Example Schema", "type": "object", "properties": { + "Kind": { + "type": "string" + }, "firstName": { "type": "string" }, @@ -38,11 +42,12 @@ lastName: bar }, "required": ["firstName", "lastName"] }`), - nil, + Valid, }, { "invalid resource", []byte(` +Kind: name firstName: foo lastName: bar `), @@ -50,6 +55,9 @@ lastName: bar "title": "Example Schema", "type": "object", "properties": { + "Kind": { + "type": "string" + }, "firstName": { "type": "number" }, @@ -64,17 +72,21 @@ lastName: bar }, "required": ["firstName", "lastName"] }`), - fmt.Errorf("Invalid type. Expected: number, given: string"), + Invalid, }, { "missing required field", []byte(` +Kind: name firstName: foo `), []byte(`{ "title": "Example Schema", "type": "object", "properties": { + "Kind": { + "type": "string" + }, "firstName": { "type": "string" }, @@ -89,11 +101,12 @@ firstName: foo }, "required": ["firstName", "lastName"] }`), - fmt.Errorf("lastName is required"), + Invalid, }, { "resource has invalid yaml", []byte(` +Kind: name firstName foo lastName: bar `), @@ -101,6 +114,9 @@ lastName: bar "title": "Example Schema", "type": "object", "properties": { + "Kind": { + "type": "string" + }, "firstName": { "type": "number" }, @@ -115,15 +131,26 @@ lastName: bar }, "required": ["firstName", "lastName"] }`), - fmt.Errorf("error unmarshalling resource: error converting YAML to JSON: yaml: line 3: mapping values are not allowed in this context"), + Error, }, } { - schema, err := gojsonschema.NewSchema(gojsonschema.NewBytesLoader(testCase.schema)) - if err != nil { - t.Errorf("failed parsing test schema") + v := Validator{ + opts: &Opts{ + SkipKinds: map[string]bool{}, + RejectKinds: map[string]bool{}, + }, + schemaCache: nil, + schemaDownload: func(_ []registry.Registry, _, _, _ string) (*gojsonschema.Schema, error) { + schema, err := gojsonschema.NewSchema(gojsonschema.NewBytesLoader(testCase.schema)) + if err != nil { + t.Errorf("failed parsing test schema") + } + return schema, nil + }, + regs: nil, } - if got := Validate(resource.Resource{Bytes: testCase.rawResource}, schema); ((got.Err == nil) != (testCase.expect == nil)) || (got.Err != nil && (got.Err.Error() != testCase.expect.Error())) { - t.Errorf("%d - expected %s, got %s", i, testCase.expect, got.Err) + if got := v.Validate(resource.Resource{Bytes: testCase.rawResource}); got.Status != testCase.expect { + t.Errorf("%d - expected %d, got %d", i, testCase.expect, got.Status) } } } From 9936e43d4785d8f13c5c2bf0a25483db813c4184 Mon Sep 17 00:00:00 2001 From: Yann Hamon Date: Sat, 14 Nov 2020 15:57:39 +0100 Subject: [PATCH 2/2] opts should not be a pointer --- cmd/kubeconform/main.go | 2 +- pkg/validator/validator.go | 4 ++-- pkg/validator/validator_test.go | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/cmd/kubeconform/main.go b/cmd/kubeconform/main.go index fb2ccdb..e9f1a71 100644 --- a/cmd/kubeconform/main.go +++ b/cmd/kubeconform/main.go @@ -65,7 +65,7 @@ func realMain() int { return 1 } - v := validator.New(cfg.SchemaLocations, &validator.Opts{ + v := validator.New(cfg.SchemaLocations, validator.Opts{ SkipTLS: cfg.SkipTLS, SkipKinds: cfg.SkipKinds, RejectKinds: cfg.RejectKinds, diff --git a/pkg/validator/validator.go b/pkg/validator/validator.go index 6cc1914..12d451e 100644 --- a/pkg/validator/validator.go +++ b/pkg/validator/validator.go @@ -22,7 +22,7 @@ const ( ) type Validator struct { - opts *Opts + opts Opts schemaCache *cache.SchemaCache schemaDownload func(registries []registry.Registry, kind, version, k8sVersion string) (*gojsonschema.Schema, error) regs []registry.Registry @@ -58,7 +58,7 @@ func downloadSchema(registries []registry.Registry, kind, version, k8sVersion st return nil, nil // No schema found - we don't consider it an error, resource will be skipped } -func New(schemaLocations []string, opts *Opts) *Validator { +func New(schemaLocations []string, opts Opts) *Validator { registries := []registry.Registry{} for _, schemaLocation := range schemaLocations { registries = append(registries, registry.New(schemaLocation, opts.Strict, opts.SkipTLS)) diff --git a/pkg/validator/validator_test.go b/pkg/validator/validator_test.go index b7259b3..2872fec 100644 --- a/pkg/validator/validator_test.go +++ b/pkg/validator/validator_test.go @@ -135,7 +135,7 @@ lastName: bar }, } { v := Validator{ - opts: &Opts{ + opts: Opts{ SkipKinds: map[string]bool{}, RejectKinds: map[string]bool{}, },