diff --git a/acceptance.bats b/acceptance.bats index 8a7e1806..3d309a1c 100755 --- a/acceptance.bats +++ b/acceptance.bats @@ -81,6 +81,46 @@ resetCacheFolder() { [ "$status" -eq 1 ] } +@test "Fail when annotation key is invalid" { + run bin/kubeconform fixtures/annotation_key_invalid.yaml + [ "$status" -eq 1 ] +} + +@test "Fail when annotation value is missing" { + run bin/kubeconform fixtures/annotation_missing_value.yaml + [ "$status" -eq 1 ] +} + +@test "Fail when annotation value is null" { + run bin/kubeconform fixtures/annotation_null_value.yaml + [ "$status" -eq 1 ] +} + +@test "Fail when label name is too long" { + run bin/kubeconform fixtures/label_name_length.yaml + [ "$status" -eq 1 ] +} + +@test "Fail when label namespace is invalid domain" { + run bin/kubeconform fixtures/label_namespace.yaml + [ "$status" -eq 1 ] +} + +@test "Fail when label value is too long" { + run bin/kubeconform fixtures/label_value_length.yaml + [ "$status" -eq 1 ] +} + +@test "Fail when metadata name is missing" { + run bin/kubeconform fixtures/metadata_name_missing.yaml + [ "$status" -eq 1 ] +} + +@test "Pass if skip-metadata added" { + run bin/kubeconform -skip-metadata fixtures/metadata_name_missing.yaml + [ "$status" -eq 0 ] +} + @test "Return relevant error for non-existent file" { run bin/kubeconform fixtures/not-here [ "$status" -eq 1 ] diff --git a/cmd/kubeconform/main.go b/cmd/kubeconform/main.go index d1ae4b3e..ba6e46ac 100644 --- a/cmd/kubeconform/main.go +++ b/cmd/kubeconform/main.go @@ -85,6 +85,7 @@ func kubeconform(cfg config.Config) int { SkipKinds: cfg.SkipKinds, RejectKinds: cfg.RejectKinds, KubernetesVersion: cfg.KubernetesVersion.String(), + SkipMetadata: cfg.SkipMetadata, Strict: cfg.Strict, IgnoreMissingSchemas: cfg.IgnoreMissingSchemas, }) diff --git a/fixtures/annotation_key_invalid.yaml b/fixtures/annotation_key_invalid.yaml new file mode 100644 index 00000000..40b2e337 --- /dev/null +++ b/fixtures/annotation_key_invalid.yaml @@ -0,0 +1,8 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: some-values + annotations: + cert(manager.io/cluster-issuer": issue #275 +data: + file.name: "a value" \ No newline at end of file diff --git a/fixtures/annotation_missing_value.yaml b/fixtures/annotation_missing_value.yaml new file mode 100644 index 00000000..48071e77 --- /dev/null +++ b/fixtures/annotation_missing_value.yaml @@ -0,0 +1,8 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: some-values + annotations: + some.domain/some-key: +data: + file.name: "a value" \ No newline at end of file diff --git a/fixtures/annotation_null_value.yaml b/fixtures/annotation_null_value.yaml new file mode 100644 index 00000000..b4ffd4c9 --- /dev/null +++ b/fixtures/annotation_null_value.yaml @@ -0,0 +1,8 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: some-values + annotations: + some.domain/some-key: null +data: + file.name: "a value" \ No newline at end of file diff --git a/fixtures/label_name_length.yaml b/fixtures/label_name_length.yaml new file mode 100644 index 00000000..c4d00444 --- /dev/null +++ b/fixtures/label_name_length.yaml @@ -0,0 +1,8 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: some-values + labels: + abcdefghijklmnopqrstuvwxyz-01234567890-ABCDEFGHIJKLMNOPQRSTUVWXYZ: "123456789_123456789_123456789_123456789_123456789_123456789_123" +data: + file.name: "a value" \ No newline at end of file diff --git a/fixtures/label_namespace.yaml b/fixtures/label_namespace.yaml new file mode 100644 index 00000000..a5dbf338 --- /dev/null +++ b/fixtures/label_namespace.yaml @@ -0,0 +1,8 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: some-values + labels: + abcdefghijklmnopqrstuvwxyz-01234567890-ABCDEFGHIJKLMNOPQRSTUVWXYZ.example.com/ABCDEFGHIJKLMNOPQRSTUVWXYZ: "123456789_123456789_123456789_123456789_123456789_123456789_123" +data: + file.name: "a value" \ No newline at end of file diff --git a/fixtures/label_value_length.yaml b/fixtures/label_value_length.yaml new file mode 100644 index 00000000..8d2aa1ff --- /dev/null +++ b/fixtures/label_value_length.yaml @@ -0,0 +1,8 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: some-values + labels: + some.domain/some-key: 123456789_123456789_123456789_123456789_123456789_123456789_1234 +data: + file.name: "a value" \ No newline at end of file diff --git a/fixtures/metadata_missing.yaml b/fixtures/metadata_missing.yaml new file mode 100644 index 00000000..7f1dac78 --- /dev/null +++ b/fixtures/metadata_missing.yaml @@ -0,0 +1,4 @@ +apiVersion: v1 +kind: ConfigMap +data: + file.name: "a value" \ No newline at end of file diff --git a/fixtures/metadata_name_missing.yaml b/fixtures/metadata_name_missing.yaml new file mode 100644 index 00000000..3b36a283 --- /dev/null +++ b/fixtures/metadata_name_missing.yaml @@ -0,0 +1,6 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + +data: + file.name: "a value" \ No newline at end of file diff --git a/fixtures/object_name-max_length.yaml b/fixtures/object_name-max_length.yaml new file mode 100644 index 00000000..b3631660 --- /dev/null +++ b/fixtures/object_name-max_length.yaml @@ -0,0 +1,6 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: abcdefghijklmnopqrstuvwxyz-01234567890-ABCDEFGHIJKLMNOPQRSTUVWXYZ +data: + file.name: "a value" \ No newline at end of file diff --git a/pkg/config/config.go b/pkg/config/config.go index b7df0f47..669d7567 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -22,6 +22,7 @@ type Config struct { RejectKinds map[string]struct{} `yaml:"reject" json:"reject"` SchemaLocations []string `yaml:"schemaLocations" json:"schemaLocations"` SkipKinds map[string]struct{} `yaml:"skip" json:"skip"` + SkipMetadata bool `yaml:"skipMetadata" json:"skipMetadata"` SkipTLS bool `yaml:"insecureSkipTLSVerify" json:"insecureSkipTLSVerify"` Strict bool `yaml:"strict" json:"strict"` Summary bool `yaml:"summary" json:"summary"` @@ -97,6 +98,7 @@ func FromFlags(progName string, args []string) (Config, string, error) { flags.StringVar(&c.OutputFormat, "output", "text", "output format - json, junit, pretty, tap, text") flags.BoolVar(&c.Verbose, "verbose", false, "print results for all resources (ignored for tap and junit output)") flags.BoolVar(&c.SkipTLS, "insecure-skip-tls-verify", false, "disable verification of the server's SSL certificate. This will make your HTTPS connections insecure") + flags.BoolVar(&c.SkipMetadata, "skip-metadata", false, "skip extra validations of metadata section") flags.StringVar(&c.Cache, "cache", "", "cache schemas downloaded via HTTP to this folder") flags.BoolVar(&c.Help, "h", false, "show help information") flags.BoolVar(&c.Version, "v", false, "show version information") diff --git a/pkg/registry/embeded.go b/pkg/registry/embeded.go new file mode 100644 index 00000000..643ce17a --- /dev/null +++ b/pkg/registry/embeded.go @@ -0,0 +1,28 @@ +package registry + +import ( + "embed" +) + +//go:embed *.json +var content embed.FS + +type EmbeddedRegistry struct { + debug bool +} + +// newEmbeddedRegistry creates a new "registry", that will serve schemas from embedded resource +func newEmbeddedRegistry(debug bool) (*EmbeddedRegistry, error) { + return &EmbeddedRegistry{ + debug, + }, nil +} + +// DownloadSchema retrieves the schema from a file for the resource +func (r EmbeddedRegistry) DownloadSchema(resourceKind, resourceAPIVersion, k8sVersion string) (string, []byte, error) { + bytes, err := content.ReadFile(resourceKind + ".json") + if err != nil { + return resourceKind, nil, nil + } + return resourceKind, bytes, nil +} diff --git a/pkg/registry/metadata.json b/pkg/registry/metadata.json new file mode 100644 index 00000000..607c64d9 --- /dev/null +++ b/pkg/registry/metadata.json @@ -0,0 +1,94 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "apiVersion": { + "$ref": "#/$defs/NAMESPACED" + }, + "kind": { + "$ref": "#/$defs/NAME" + }, + "metadata": { + "type": "object", + "properties": { + "name": { + "$ref": "#/$defs/RFC-1123" + }, + "generateName": { + "$ref": "#/$defs/RFC-1123-prefix" + }, + "annotations": { + "type": "object", + "propertyNames": { + "$ref": "#/$defs/NAMESPACED" + }, + "patternProperties": { + "^.+$": { + "type": "string", + "minLength": 1 + } + } + }, + "labels": { + "type": "object", + "propertyNames": { + "$ref": "#/$defs/NAMESPACED" + }, + "patternProperties": { + "^.+$": { + "$ref": "#/$defs/NAME" + } + } + } + }, + "oneOf": [ + { + "required": [ + "name" + ] + }, + { + "required": [ + "generateName" + ] + } + ] + } + }, + "required": [ + "apiVersion", + "kind", + "metadata" + ], + "$defs": { + "NAMESPACED": { + "allOf": [ + { + "pattern": "^(.{0,253}/)?.{1,63}$", + "type": "string" + }, + { + "pattern": "^([a-z0-9-]{1,63}(\\.[a-z0-9-]{1,63})*/)?[a-z0-9A-Z]+([_.-][a-z0-9A-Z]+)*$" + } + ] + }, + "NAME": { + "type": "string", + "minLength": 1, + "maxLength": 63, + "pattern": "^[a-z0-9A-Z]+([_.-][a-z0-9A-Z]+)*$" + }, + "RFC-1123": { + "type": "string", + "minLength": 1, + "maxLength": 63, + "pattern": "^[a-z0-9]+(-+[a-z0-9]+)*$" + }, + "RFC-1123-prefix": { + "type": "string", + "minLength": 1, + "maxLength": 58, + "pattern": "^[a-z0-9]+[a-z0-9-]*$" + } + } +} \ No newline at end of file diff --git a/pkg/registry/registry.go b/pkg/registry/registry.go index 154f40c4..28b64735 100644 --- a/pkg/registry/registry.go +++ b/pkg/registry/registry.go @@ -97,5 +97,9 @@ func New(schemaLocation string, cache string, strict bool, skipTLS bool, debug b return newHTTPRegistry(schemaLocation, cache, strict, skipTLS, debug) } + if strings.HasPrefix(schemaLocation, "embedded") { + return newEmbeddedRegistry(debug) + } + return newLocalRegistry(schemaLocation, strict, debug) } diff --git a/pkg/validator/validator.go b/pkg/validator/validator.go index 0f8e85a7..66d12f70 100644 --- a/pkg/validator/validator.go +++ b/pkg/validator/validator.go @@ -58,6 +58,7 @@ type Opts struct { 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 + SkipMetadata bool // skip extra validation of metadata 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 @@ -80,6 +81,10 @@ func New(schemaLocations []string, opts Opts) (Validator, error) { } registries = append(registries, reg) } + if !opts.SkipMetadata { + reg, _ := registry.New("embedded", opts.Cache, opts.Strict, opts.SkipTLS, opts.Debug) + registries = append(registries, reg) + } if opts.KubernetesVersion == "" { opts.KubernetesVersion = "master" @@ -107,6 +112,48 @@ type v struct { regs []registry.Registry } +func (val *v) fetchFromCache(sig *resource.Signature) (*jsonschema.Schema, error) { + var schema *jsonschema.Schema + + if val.schemaCache != nil { + s, err := val.schemaCache.Get(sig.Kind, sig.Version, val.opts.KubernetesVersion) + if err == nil { + return s.(*jsonschema.Schema), nil + } + } + + schema, err := val.schemaDownload(val.regs, sig.Kind, sig.Version, val.opts.KubernetesVersion) + + if err != nil { + return nil, err + } + + if val.schemaCache != nil { + val.schemaCache.Set(sig.Kind, sig.Version, val.opts.KubernetesVersion, schema) + } + return schema, nil +} + +func validate(schema *jsonschema.Schema, r map[string]interface{}, validationErrors []ValidationError) ([]ValidationError, error) { + err := schema.Validate(r) + if err != nil { + 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 validationErrors, err +} + +var metadata = resource.Signature{ + Kind: "metadata", +} + // ValidateResource validates a single resource. This allows to validate // large resource streams using multiple Go Routines. func (val *v) ValidateResource(res resource.Resource) Result { @@ -162,25 +209,9 @@ func (val *v) ValidateResource(res resource.Resource) Result { 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) - } + schema, err := val.fetchFromCache(sig) + if err != nil { + return Result{Resource: res, Err: err, Status: Error} } if schema == nil { @@ -191,27 +222,24 @@ func (val *v) ValidateResource(res resource.Resource) Result { return Result{Resource: res, Err: fmt.Errorf("could not find schema for %s", sig.Kind), Status: Error} } - 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, - }) - } + validationErrors, ve := validate(schema, r, []ValidationError{}) + if !val.opts.SkipMetadata { + metaSchema, _ := val.fetchFromCache(&metadata) + validationErrors, err = validate(metaSchema, r, validationErrors) + if ve == nil { + ve = err } + } + + if len(validationErrors) > 0 { 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", ve), ValidationErrors: validationErrors, } } - return Result{Resource: res, Status: Valid} } diff --git a/pkg/validator/validator_test.go b/pkg/validator/validator_test.go index aa86951a..8e5bfae4 100644 --- a/pkg/validator/validator_test.go +++ b/pkg/validator/validator_test.go @@ -381,6 +381,7 @@ lastName: bar val := v{ opts: Opts{ SkipKinds: map[string]struct{}{}, + SkipMetadata: true, RejectKinds: map[string]struct{}{}, IgnoreMissingSchemas: testCase.ignoreMissingSchema, Strict: testCase.strict, @@ -453,8 +454,9 @@ age: not a number val := v{ opts: Opts{ - SkipKinds: map[string]struct{}{}, - RejectKinds: map[string]struct{}{}, + SkipKinds: map[string]struct{}{}, + SkipMetadata: true, + RejectKinds: map[string]struct{}{}, }, schemaCache: nil, schemaDownload: downloadSchema, @@ -502,8 +504,9 @@ firstName: foo val := v{ opts: Opts{ - SkipKinds: map[string]struct{}{}, - RejectKinds: map[string]struct{}{}, + SkipKinds: map[string]struct{}{}, + SkipMetadata: true, + RejectKinds: map[string]struct{}{}, }, schemaCache: nil, schemaDownload: downloadSchema, diff --git a/site/content/docs/usage.md b/site/content/docs/usage.md index 71ff14d9..db047ba2 100644 --- a/site/content/docs/usage.md +++ b/site/content/docs/usage.md @@ -33,6 +33,8 @@ Usage: ./bin/kubeconform [OPTION]... [FILE OR FOLDER]... override schemas location search path (can be specified multiple times) -skip string comma-separated list of kinds to ignore + -skip-metadata + skip extra validations of metadata section -strict disallow additional properties not in schema or duplicated keys -summary