mirror of
https://github.com/yannh/kubeconform.git
synced 2026-02-11 14:09:21 +00:00
331 lines
9.9 KiB
Go
331 lines
9.9 KiB
Go
// This is the main package to import to embed kubeconform in your software
|
|
package validator
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
|
|
jsonschema "github.com/santhosh-tekuri/jsonschema/v5"
|
|
_ "github.com/santhosh-tekuri/jsonschema/v5/httploader"
|
|
"github.com/yannh/kubeconform/pkg/cache"
|
|
"github.com/yannh/kubeconform/pkg/registry"
|
|
"github.com/yannh/kubeconform/pkg/resource"
|
|
"sigs.k8s.io/yaml"
|
|
)
|
|
|
|
// Different types of validation results
|
|
type Status int
|
|
|
|
const (
|
|
_ Status = iota
|
|
Error // an error occurred processing the file / resource
|
|
Skipped // resource has been skipped, for example if its Kind was part of the kinds to skip
|
|
Valid // resource is valid
|
|
Invalid // resource is invalid
|
|
Empty // resource is empty. Note: is triggered for files starting with a --- separator.
|
|
)
|
|
|
|
type ValidationError struct {
|
|
Path string `json:"path"`
|
|
Msg string `json:"msg"`
|
|
}
|
|
|
|
func (ve *ValidationError) Error() string {
|
|
return ve.Msg
|
|
}
|
|
|
|
// Result contains the details of the result of a resource validation
|
|
type Result struct {
|
|
Resource resource.Resource
|
|
Err error
|
|
Status Status
|
|
ValidationErrors []ValidationError
|
|
}
|
|
|
|
// Validator exposes multiple methods to validate your Kubernetes resources.
|
|
type Validator interface {
|
|
ValidateResource(res resource.Resource) Result
|
|
Validate(filename string, r io.ReadCloser) []Result
|
|
ValidateWithContext(ctx context.Context, filename string, r io.ReadCloser) []Result
|
|
}
|
|
|
|
// Opts contains a set of options for the validator.
|
|
type Opts struct {
|
|
Cache string // Cache schemas downloaded via HTTP to this folder
|
|
Debug bool // Debug infos will be print here
|
|
SkipTLS bool // skip TLS validation when downloading from an HTTP Schema Registry
|
|
SkipKinds map[string]struct{} // List of resource Kinds to ignore
|
|
RejectKinds map[string]struct{} // List of resource Kinds to reject
|
|
KubernetesVersion string // Kubernetes Version - has to match one in https://github.com/instrumenta/kubernetes-json-schema
|
|
Strict bool // thros an error if resources contain undocumented fields
|
|
StrictExceptions map[string]struct{} // list of field paths (e.g. spec.template.metadata) to ignore when strict is enabled
|
|
IgnoreMissingSchemas bool // skip a resource if no schema for that resource can be found
|
|
}
|
|
|
|
// New returns a new Validator
|
|
func New(schemaLocations []string, opts Opts) (Validator, error) {
|
|
// Default to our kubernetes-json-schema fork
|
|
// raw.githubusercontent.com is frontend by Fastly and very fast
|
|
defaultLocationsUsed := false
|
|
if len(schemaLocations) == 0 {
|
|
schemaLocations = []string{"https://raw.githubusercontent.com/yannh/kubernetes-json-schema/master/{{ .NormalizedKubernetesVersion }}-standalone{{ .StrictSuffix }}/{{ .ResourceKind }}{{ .KindSuffix }}.json"}
|
|
defaultLocationsUsed = true
|
|
}
|
|
|
|
registries := []registry.Registry{}
|
|
for _, schemaLocation := range schemaLocations {
|
|
reg, err := registry.New(schemaLocation, opts.Cache, opts.Strict, opts.SkipTLS, opts.Debug)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
registries = append(registries, reg)
|
|
}
|
|
|
|
if opts.KubernetesVersion == "" {
|
|
opts.KubernetesVersion = "master"
|
|
}
|
|
|
|
if opts.SkipKinds == nil {
|
|
opts.SkipKinds = map[string]struct{}{}
|
|
}
|
|
if opts.RejectKinds == nil {
|
|
opts.RejectKinds = map[string]struct{}{}
|
|
}
|
|
if opts.StrictExceptions == nil {
|
|
opts.StrictExceptions = map[string]struct{}{}
|
|
}
|
|
if len(opts.StrictExceptions) == 0 {
|
|
// If no strict exceptions are specified, we add the metadata field
|
|
// as it is mostly not autogenerated in the OpenAPI schemas generated from CRDs
|
|
opts.StrictExceptions["metadata"] = struct{}{}
|
|
}
|
|
|
|
return &v{
|
|
opts: opts,
|
|
schemaDownload: downloadSchema,
|
|
schemaCache: cache.NewInMemoryCache(),
|
|
regs: registries,
|
|
defaultLocationsUsed: defaultLocationsUsed,
|
|
}, nil
|
|
}
|
|
|
|
func (val *v) setStrictSchema(schema *jsonschema.Schema, prefixPath string) {
|
|
if _, ok := val.opts.StrictExceptions[prefixPath]; ok {
|
|
return
|
|
}
|
|
if schema.AdditionalProperties == nil {
|
|
schema.AdditionalProperties = false
|
|
}
|
|
if schema.Items != nil {
|
|
casted, ok := schema.Items.(*jsonschema.Schema)
|
|
if ok {
|
|
fullPath := prefixPath + ".0"
|
|
val.setStrictSchema(casted, fullPath)
|
|
} else {
|
|
casted, ok := schema.Items.([]*jsonschema.Schema)
|
|
if ok {
|
|
for i, s := range casted {
|
|
fullPath := prefixPath + "." + fmt.Sprintf("%d", i)
|
|
val.setStrictSchema(s, fullPath)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
for name, s := range schema.Properties {
|
|
fullPath := prefixPath + "." + name
|
|
if fullPath[0] == '.' {
|
|
fullPath = fullPath[1:]
|
|
}
|
|
val.setStrictSchema(s, fullPath)
|
|
}
|
|
}
|
|
|
|
type v struct {
|
|
opts Opts
|
|
schemaCache cache.Cache
|
|
schemaDownload func(registries []registry.Registry, kind, version, k8sVersion string) (*jsonschema.Schema, error)
|
|
regs []registry.Registry
|
|
defaultLocationsUsed bool
|
|
}
|
|
|
|
// ValidateResource validates a single resource. This allows to validate
|
|
// large resource streams using multiple Go Routines.
|
|
func (val *v) ValidateResource(res resource.Resource) Result {
|
|
// For backward compatibility reasons when determining whether
|
|
// a resource should be skipped or rejected we use both
|
|
// the GVK encoding of the resource signatures (the recommended method
|
|
// for skipping/rejecting resources) and the raw Kind.
|
|
|
|
skip := func(signature resource.Signature) bool {
|
|
if _, ok := val.opts.SkipKinds[signature.GroupVersionKind()]; ok {
|
|
return ok
|
|
}
|
|
_, ok := val.opts.SkipKinds[signature.Kind]
|
|
return ok
|
|
}
|
|
|
|
reject := func(signature resource.Signature) bool {
|
|
if _, ok := val.opts.RejectKinds[signature.GroupVersionKind()]; ok {
|
|
return ok
|
|
}
|
|
_, ok := val.opts.RejectKinds[signature.Kind]
|
|
return ok
|
|
}
|
|
|
|
if len(res.Bytes) == 0 {
|
|
return Result{Resource: res, Err: nil, Status: Empty}
|
|
}
|
|
|
|
var r map[string]interface{}
|
|
unmarshaller := yaml.Unmarshal
|
|
if val.opts.Strict {
|
|
unmarshaller = yaml.UnmarshalStrict
|
|
}
|
|
|
|
if err := unmarshaller(res.Bytes, &r); err != nil {
|
|
return Result{Resource: res, Status: Error, Err: fmt.Errorf("error unmarshalling resource: %s", err)}
|
|
}
|
|
|
|
if r == nil { // Resource is empty
|
|
return Result{Resource: res, Err: nil, Status: Empty}
|
|
}
|
|
|
|
sig, err := res.SignatureFromMap(r)
|
|
if err != nil {
|
|
return Result{Resource: res, Err: fmt.Errorf("error while parsing: %s", err), Status: Error}
|
|
}
|
|
|
|
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 *jsonschema.Schema
|
|
|
|
if val.schemaCache != nil {
|
|
s, err := val.schemaCache.Get(sig.Kind, sig.Version, val.opts.KubernetesVersion)
|
|
if err == nil {
|
|
cached = true
|
|
schema = s.(*jsonschema.Schema)
|
|
}
|
|
}
|
|
|
|
if !cached {
|
|
if schema, err = val.schemaDownload(val.regs, sig.Kind, sig.Version, val.opts.KubernetesVersion); err != nil {
|
|
return Result{Resource: res, Err: err, Status: Error}
|
|
}
|
|
|
|
if val.schemaCache != nil {
|
|
val.schemaCache.Set(sig.Kind, sig.Version, val.opts.KubernetesVersion, schema)
|
|
}
|
|
}
|
|
|
|
if schema == nil {
|
|
if val.opts.IgnoreMissingSchemas {
|
|
return Result{Resource: res, Err: nil, Status: Skipped}
|
|
}
|
|
|
|
return Result{Resource: res, Err: fmt.Errorf("could not find schema for %s", sig.Kind), Status: Error}
|
|
}
|
|
|
|
// If strict mode is enabled, we set additionalProperties to false
|
|
// if not explicitly set in the schema
|
|
if val.opts.Strict && !val.defaultLocationsUsed {
|
|
val.setStrictSchema(schema, "")
|
|
}
|
|
err = schema.Validate(r)
|
|
if err != nil {
|
|
validationErrors := []ValidationError{}
|
|
var e *jsonschema.ValidationError
|
|
if errors.As(err, &e) {
|
|
for _, ve := range e.Causes {
|
|
validationErrors = append(validationErrors, ValidationError{
|
|
Path: ve.InstanceLocation,
|
|
Msg: ve.Message,
|
|
})
|
|
}
|
|
|
|
}
|
|
return Result{
|
|
Resource: res,
|
|
Status: Invalid,
|
|
Err: fmt.Errorf("problem validating schema. Check JSON formatting: %s", err),
|
|
ValidationErrors: validationErrors,
|
|
}
|
|
}
|
|
|
|
return Result{Resource: res, Status: Valid}
|
|
}
|
|
|
|
// ValidateWithContext validates resources found in r
|
|
// filename should be a name for the stream, such as a filename or stdin
|
|
func (val *v) ValidateWithContext(ctx context.Context, filename string, r io.ReadCloser) []Result {
|
|
validationResults := []Result{}
|
|
resourcesChan, _ := resource.FromStream(ctx, filename, r)
|
|
for {
|
|
select {
|
|
case res, ok := <-resourcesChan:
|
|
if ok {
|
|
validationResults = append(validationResults, val.ValidateResource(res))
|
|
} else {
|
|
resourcesChan = nil
|
|
}
|
|
|
|
case <-ctx.Done():
|
|
break
|
|
}
|
|
|
|
if resourcesChan == nil {
|
|
break
|
|
}
|
|
}
|
|
|
|
r.Close()
|
|
return validationResults
|
|
}
|
|
|
|
// Validate validates resources found in r
|
|
// filename should be a name for the stream, such as a filename or stdin
|
|
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) {
|
|
var err error
|
|
var schemaBytes []byte
|
|
var path string
|
|
|
|
for _, reg := range registries {
|
|
path, schemaBytes, err = reg.DownloadSchema(kind, version, k8sVersion)
|
|
if err == nil {
|
|
c := jsonschema.NewCompiler()
|
|
c.Draft = jsonschema.Draft4
|
|
if err := c.AddResource(path, bytes.NewReader(schemaBytes)); err != nil {
|
|
continue
|
|
}
|
|
schema, err := c.Compile(path)
|
|
// If we got a non-parseable response, we try the next registry
|
|
if err != nil {
|
|
continue
|
|
}
|
|
return schema, err
|
|
}
|
|
|
|
// 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
|
|
}
|