From b578f6641998825c2fa0daf278c5d6c6f9cc3541 Mon Sep 17 00:00:00 2001 From: Yann Hamon Date: Sat, 10 May 2025 22:11:18 +0200 Subject: [PATCH] WIP --- Dockerfile.bats | 2 +- acceptance.bats | 4 +- pkg/loader/http.go | 72 ++++++++++++++++++++++++++++ pkg/loader/loaders.go | 12 +++++ pkg/registry/http.go | 98 ++++---------------------------------- pkg/registry/local.go | 10 ++-- pkg/registry/registry.go | 28 +++++++++-- pkg/validator/validator.go | 56 +++++++++++++++------- 8 files changed, 165 insertions(+), 117 deletions(-) create mode 100644 pkg/loader/http.go create mode 100644 pkg/loader/loaders.go diff --git a/Dockerfile.bats b/Dockerfile.bats index 181743b..5d4e4fd 100644 --- a/Dockerfile.bats +++ b/Dockerfile.bats @@ -1,5 +1,5 @@ FROM bats/bats:1.11.0 RUN apk --no-cache add ca-certificates parallel libxml2-utils -COPY dist/kubeconform_linux_amd64_v1/kubeconform /code/bin/ +COPY bin/kubeconform /code/bin/ COPY acceptance.bats acceptance-nonetwork.bats /code/ COPY fixtures /code/fixtures diff --git a/acceptance.bats b/acceptance.bats index 8a7e180..5cdd7de 100755 --- a/acceptance.bats +++ b/acceptance.bats @@ -299,14 +299,14 @@ resetCacheFolder() { @test "Fail when parsing a List that contains an invalid resource" { run bin/kubeconform -summary fixtures/list_invalid.yaml [ "$status" -eq 1 ] - [ "${lines[0]}" == 'fixtures/list_invalid.yaml - ReplicationController bob is invalid: problem validating schema. Check JSON formatting: jsonschema: '\''/spec/replicas'\'' does not validate with https://raw.githubusercontent.com/yannh/kubernetes-json-schema/master/master-standalone/replicationcontroller-v1.json#/properties/spec/properties/replicas/type: expected integer or null, but got string' ] + [ "${lines[0]}" == 'fixtures/list_invalid.yaml - ReplicationController bob is invalid: problem validating schema. Check JSON formatting: jsonschema validation failed with '\''https://raw.githubusercontent.com/yannh/kubernetes-json-schema/master/master-standalone/replicationcontroller-v1.json#'\'' - at '\''/spec/replicas'\'': got string, want null or integer' ] [ "${lines[1]}" == 'Summary: 2 resources found in 1 file - Valid: 1, Invalid: 1, Errors: 0, Skipped: 0' ] } @test "Fail when parsing a List that contains an invalid resource from stdin" { run bash -c "cat fixtures/list_invalid.yaml | bin/kubeconform -summary -" [ "$status" -eq 1 ] - [ "${lines[0]}" == 'stdin - ReplicationController bob is invalid: problem validating schema. Check JSON formatting: jsonschema: '\''/spec/replicas'\'' does not validate with https://raw.githubusercontent.com/yannh/kubernetes-json-schema/master/master-standalone/replicationcontroller-v1.json#/properties/spec/properties/replicas/type: expected integer or null, but got string' ] + [ "${lines[0]}" == 'stdin - ReplicationController bob is invalid: problem validating schema. Check JSON formatting: jsonschema validation failed with '\''https://raw.githubusercontent.com/yannh/kubernetes-json-schema/master/master-standalone/replicationcontroller-v1.json#'\'' - at '\''/spec/replicas'\'': got string, want null or integer' ] [ "${lines[1]}" == 'Summary: 2 resources found parsing stdin - Valid: 1, Invalid: 1, Errors: 0, Skipped: 0' ] } diff --git a/pkg/loader/http.go b/pkg/loader/http.go new file mode 100644 index 0000000..21fe2f3 --- /dev/null +++ b/pkg/loader/http.go @@ -0,0 +1,72 @@ +package loader + +import ( + "bytes" + "crypto/tls" + "errors" + "fmt" + "github.com/hashicorp/go-retryablehttp" + "github.com/santhosh-tekuri/jsonschema/v6" + "github.com/yannh/kubeconform/pkg/cache" + "io" + "net/http" + "time" +) + +type HTTPURLLoader struct { + client http.Client + cache cache.Cache +} + +func (l *HTTPURLLoader) Load(url string) (any, error) { + resp, err := l.client.Get(url) + if err != nil { + msg := fmt.Sprintf("failed downloading schema at %s: %s", url, err) + return nil, errors.New(msg) + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusNotFound { + msg := fmt.Sprintf("could not find schema at %s", url) + return nil, NewNotFoundError(errors.New(msg)) + } + + if resp.StatusCode != http.StatusOK { + msg := fmt.Sprintf("error while downloading schema at %s - received HTTP status %d", url, resp.StatusCode) + return nil, fmt.Errorf("%s", msg) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + msg := fmt.Sprintf("failed parsing schema from %s: %s", url, err) + return nil, errors.New(msg) + } + + if l.cache != nil { + // To implement + } + + return jsonschema.UnmarshalJSON(bytes.NewReader(body)) +} + +func NewHTTPURLLoader(skipTLS bool, cache cache.Cache) (*HTTPURLLoader, error) { + transport := &http.Transport{ + MaxIdleConns: 100, + IdleConnTimeout: 3 * time.Second, + DisableCompression: true, + Proxy: http.ProxyFromEnvironment, + } + + if skipTLS { + transport.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} + } + + // retriable http client + retryClient := retryablehttp.NewClient() + retryClient.RetryMax = 2 + retryClient.HTTPClient = &http.Client{Transport: transport} + retryClient.Logger = nil + + httpLoader := HTTPURLLoader{client: *retryClient.StandardClient(), cache: cache} + return &httpLoader, nil +} diff --git a/pkg/loader/loaders.go b/pkg/loader/loaders.go new file mode 100644 index 0000000..33e10ca --- /dev/null +++ b/pkg/loader/loaders.go @@ -0,0 +1,12 @@ +package loader + +// NotFoundError is returned when the registry does not contain a schema for the resource +type NotFoundError struct { + err error +} + +func NewNotFoundError(err error) *NotFoundError { + return &NotFoundError{err} +} +func (e *NotFoundError) Error() string { return e.err.Error() } +func (e *NotFoundError) Retryable() bool { return false } diff --git a/pkg/registry/http.go b/pkg/registry/http.go index 35067c8..013e85a 100644 --- a/pkg/registry/http.go +++ b/pkg/registry/http.go @@ -1,17 +1,9 @@ package registry import ( - "crypto/tls" - "errors" - "fmt" - "io" - "log" - "net/http" - "os" - "time" - - retryablehttp "github.com/hashicorp/go-retryablehttp" + "github.com/santhosh-tekuri/jsonschema/v6" "github.com/yannh/kubeconform/pkg/cache" + "net/http" ) type httpGetter interface { @@ -20,55 +12,24 @@ type httpGetter interface { // SchemaRegistry is a file repository (local or remote) that contains JSON schemas for Kubernetes resources type SchemaRegistry struct { - c httpGetter schemaPathTemplate string cache cache.Cache strict bool debug bool + loader jsonschema.URLLoader } -func newHTTPRegistry(schemaPathTemplate string, cacheFolder string, strict bool, skipTLS bool, debug bool) (*SchemaRegistry, error) { - reghttp := &http.Transport{ - MaxIdleConns: 100, - IdleConnTimeout: 3 * time.Second, - DisableCompression: true, - Proxy: http.ProxyFromEnvironment, - } - - if skipTLS { - reghttp.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} - } - - var filecache cache.Cache = nil - if cacheFolder != "" { - fi, err := os.Stat(cacheFolder) - if err != nil { - return nil, fmt.Errorf("failed opening cache folder %s: %s", cacheFolder, err) - } - if !fi.IsDir() { - return nil, fmt.Errorf("cache folder %s is not a directory", err) - } - - filecache = cache.NewOnDiskCache(cacheFolder) - } - - // retriable http client - retryClient := retryablehttp.NewClient() - retryClient.RetryMax = 2 - retryClient.HTTPClient = &http.Client{Transport: reghttp} - retryClient.Logger = nil - +func newHTTPRegistry(schemaPathTemplate string, loader jsonschema.URLLoader, strict bool, debug bool) (*SchemaRegistry, error) { return &SchemaRegistry{ - c: retryClient.StandardClient(), schemaPathTemplate: schemaPathTemplate, - cache: filecache, strict: strict, + loader: loader, debug: debug, }, nil } // DownloadSchema downloads the schema for a particular resource from an HTTP server -func (r SchemaRegistry) DownloadSchema(resourceKind, resourceAPIVersion, k8sVersion string) (string, []byte, error) { +func (r SchemaRegistry) DownloadSchema(resourceKind, resourceAPIVersion, k8sVersion string) (string, any, error) { url, err := schemaPath(r.schemaPathTemplate, resourceKind, resourceAPIVersion, k8sVersion, r.strict) if err != nil { return "", nil, err @@ -80,50 +41,7 @@ func (r SchemaRegistry) DownloadSchema(resourceKind, resourceAPIVersion, k8sVers } } - resp, err := r.c.Get(url) - if err != nil { - msg := fmt.Sprintf("failed downloading schema at %s: %s", url, err) - if r.debug { - log.Println(msg) - } - return url, nil, errors.New(msg) - } - defer resp.Body.Close() + resp, err := r.loader.Load(url) - if resp.StatusCode == http.StatusNotFound { - msg := fmt.Sprintf("could not find schema at %s", url) - if r.debug { - log.Print(msg) - } - return url, nil, newNotFoundError(errors.New(msg)) - } - - if resp.StatusCode != http.StatusOK { - msg := fmt.Sprintf("error while downloading schema at %s - received HTTP status %d", url, resp.StatusCode) - if r.debug { - log.Print(msg) - } - return url, nil, fmt.Errorf("%s", msg) - } - - body, err := io.ReadAll(resp.Body) - if err != nil { - msg := fmt.Sprintf("failed parsing schema from %s: %s", url, err) - if r.debug { - log.Print(msg) - } - return url, nil, errors.New(msg) - } - - if r.debug { - log.Printf("using schema found at %s", url) - } - - if r.cache != nil { - if err := r.cache.Set(resourceKind, resourceAPIVersion, k8sVersion, body); err != nil { - return url, nil, fmt.Errorf("failed writing schema to cache: %s", err) - } - } - - return url, body, nil + return url, resp, nil } diff --git a/pkg/registry/local.go b/pkg/registry/local.go index 7501290..5599d46 100644 --- a/pkg/registry/local.go +++ b/pkg/registry/local.go @@ -1,8 +1,10 @@ package registry import ( + "bytes" "errors" "fmt" + "github.com/santhosh-tekuri/jsonschema/v6" "io" "log" "os" @@ -24,7 +26,7 @@ func newLocalRegistry(pathTemplate string, strict bool, debug bool) (*LocalRegis } // DownloadSchema retrieves the schema from a file for the resource -func (r LocalRegistry) DownloadSchema(resourceKind, resourceAPIVersion, k8sVersion string) (string, []byte, error) { +func (r LocalRegistry) DownloadSchema(resourceKind, resourceAPIVersion, k8sVersion string) (string, any, error) { schemaFile, err := schemaPath(r.pathTemplate, resourceKind, resourceAPIVersion, k8sVersion, r.strict) if err != nil { return schemaFile, []byte{}, nil @@ -36,7 +38,7 @@ func (r LocalRegistry) DownloadSchema(resourceKind, resourceAPIVersion, k8sVersi if r.debug { log.Print(msg) } - return schemaFile, nil, newNotFoundError(errors.New(msg)) + return schemaFile, nil, NewNotFoundError(errors.New(msg)) } msg := fmt.Sprintf("failed to open schema at %s: %s", schemaFile, err) @@ -59,5 +61,7 @@ func (r LocalRegistry) DownloadSchema(resourceKind, resourceAPIVersion, k8sVersi if r.debug { log.Printf("using schema found at %s", schemaFile) } - return schemaFile, content, nil + + b, err := jsonschema.UnmarshalJSON(bytes.NewReader(content)) + return schemaFile, b, err } diff --git a/pkg/registry/registry.go b/pkg/registry/registry.go index 154f40c..4b4b7eb 100644 --- a/pkg/registry/registry.go +++ b/pkg/registry/registry.go @@ -3,6 +3,9 @@ package registry import ( "bytes" "fmt" + "github.com/yannh/kubeconform/pkg/cache" + "github.com/yannh/kubeconform/pkg/loader" + "os" "strings" "text/template" ) @@ -13,7 +16,7 @@ type Manifest struct { // Registry is an interface that should be implemented by any source of Kubernetes schemas type Registry interface { - DownloadSchema(resourceKind, resourceAPIVersion, k8sVersion string) (string, []byte, error) + DownloadSchema(resourceKind, resourceAPIVersion, k8sVersion string) (string, any, error) } // Retryable indicates whether an error is a temporary or a permanent failure @@ -26,7 +29,7 @@ type NotFoundError struct { err error } -func newNotFoundError(err error) *NotFoundError { +func NewNotFoundError(err error) *NotFoundError { return &NotFoundError{err} } func (e *NotFoundError) Error() string { return e.err.Error() } @@ -81,7 +84,7 @@ func schemaPath(tpl, resourceKind, resourceAPIVersion, k8sVersion string, strict return buf.String(), nil } -func New(schemaLocation string, cache string, strict bool, skipTLS bool, debug bool) (Registry, error) { +func New(schemaLocation string, cacheFolder string, strict bool, skipTLS bool, debug bool) (Registry, error) { if schemaLocation == "default" { schemaLocation = "https://raw.githubusercontent.com/yannh/kubernetes-json-schema/master/{{ .NormalizedKubernetesVersion }}-standalone{{ .StrictSuffix }}/{{ .ResourceKind }}{{ .KindSuffix }}.json" } else if !strings.HasSuffix(schemaLocation, "json") { // If we dont specify a full templated path, we assume the paths of our fork of kubernetes-json-schema @@ -93,8 +96,25 @@ func New(schemaLocation string, cache string, strict bool, skipTLS bool, debug b return nil, fmt.Errorf("failed initialising schema location registry: %s", err) } + var filecache cache.Cache = nil + if cacheFolder != "" { + fi, err := os.Stat(cacheFolder) + if err != nil { + return nil, fmt.Errorf("failed opening cache folder %s: %s", cacheFolder, err) + } + if !fi.IsDir() { + return nil, fmt.Errorf("cache folder %s is not a directory", err) + } + + filecache = cache.NewOnDiskCache(cacheFolder) + } + if strings.HasPrefix(schemaLocation, "http") { - return newHTTPRegistry(schemaLocation, cache, strict, skipTLS, debug) + httpLoader, err := loader.NewHTTPURLLoader(skipTLS, filecache) + if err != nil { + return nil, fmt.Errorf("failed creating HTTP loader: %s", err) + } + return newHTTPRegistry(schemaLocation, httpLoader, strict, debug) } return newLocalRegistry(schemaLocation, strict, debug) diff --git a/pkg/validator/validator.go b/pkg/validator/validator.go index 9e8ae0d..36a749c 100644 --- a/pkg/validator/validator.go +++ b/pkg/validator/validator.go @@ -2,19 +2,20 @@ package validator import ( - "bytes" "context" "errors" "fmt" + jsonschema "github.com/santhosh-tekuri/jsonschema/v6" + "github.com/yannh/kubeconform/pkg/cache" + "github.com/yannh/kubeconform/pkg/loader" + "github.com/yannh/kubeconform/pkg/registry" + "github.com/yannh/kubeconform/pkg/resource" "golang.org/x/text/language" "golang.org/x/text/message" "io" - - jsonschema "github.com/santhosh-tekuri/jsonschema/v6" - "github.com/yannh/kubeconform/pkg/cache" - "github.com/yannh/kubeconform/pkg/registry" - "github.com/yannh/kubeconform/pkg/resource" + "os" "sigs.k8s.io/yaml" + "strings" ) // Different types of validation results @@ -93,19 +94,43 @@ func New(schemaLocations []string, opts Opts) (Validator, error) { opts.RejectKinds = map[string]struct{}{} } + var filecache cache.Cache = nil + if opts.Cache != "" { + fi, err := os.Stat(opts.Cache) + if err != nil { + return nil, fmt.Errorf("failed opening cache folder %s: %s", opts.Cache, err) + } + if !fi.IsDir() { + return nil, fmt.Errorf("cache folder %s is not a directory", err) + } + + filecache = cache.NewOnDiskCache(opts.Cache) + } + + httpLoader, err := loader.NewHTTPURLLoader(false, filecache) + if err != nil { + return nil, fmt.Errorf("failed creating HTTP loader: %s", err) + } + return &v{ opts: opts, schemaDownload: downloadSchema, schemaCache: cache.NewInMemoryCache(), regs: registries, + loader: jsonschema.SchemeURLLoader{ + "file": jsonschema.FileLoader{}, + "http": httpLoader, + "https": httpLoader, + }, }, nil } type v struct { opts Opts schemaCache cache.Cache - schemaDownload func(registries []registry.Registry, kind, version, k8sVersion string) (*jsonschema.Schema, error) + schemaDownload func(registries []registry.Registry, loader jsonschema.SchemeURLLoader, kind, version, k8sVersion string) (*jsonschema.Schema, error) regs []registry.Registry + loader jsonschema.SchemeURLLoader } // ValidateResource validates a single resource. This allows to validate @@ -175,7 +200,7 @@ func (val *v) ValidateResource(res resource.Resource) Result { } if !cached { - if schema, err = val.schemaDownload(val.regs, sig.Kind, sig.Version, val.opts.KubernetesVersion); err != nil { + if schema, err = val.schemaDownload(val.regs, val.loader, sig.Kind, sig.Version, val.opts.KubernetesVersion); err != nil { return Result{Resource: res, Err: err, Status: Error} } @@ -209,10 +234,11 @@ func (val *v) ValidateResource(res resource.Resource) Result { } } + return Result{ Resource: res, Status: Invalid, - Err: fmt.Errorf("problem validating schema. Check JSON formatting: %s", err), + Err: fmt.Errorf("problem validating schema. Check JSON formatting: %s", strings.ReplaceAll(err.Error(), "\n", " ")), ValidationErrors: validationErrors, } } @@ -253,20 +279,16 @@ func (val *v) Validate(filename string, r io.ReadCloser) []Result { return val.ValidateWithContext(context.Background(), filename, r) } -func downloadSchema(registries []registry.Registry, kind, version, k8sVersion string) (*jsonschema.Schema, error) { +func downloadSchema(registries []registry.Registry, loader jsonschema.SchemeURLLoader, kind, version, k8sVersion string) (*jsonschema.Schema, error) { var err error - var schemaBytes []byte var path string + var s any for _, reg := range registries { - path, schemaBytes, err = reg.DownloadSchema(kind, version, k8sVersion) + path, s, err = reg.DownloadSchema(kind, version, k8sVersion) if err == nil { - s, err := jsonschema.UnmarshalJSON(bytes.NewReader(schemaBytes)) - if err != nil { - continue - } - c := jsonschema.NewCompiler() + c.UseLoader(loader) c.DefaultDraft(jsonschema.Draft4) if err := c.AddResource(path, s); err != nil { continue