diff --git a/acceptance.bats b/acceptance.bats index 8a7e1806..84725a77 100755 --- a/acceptance.bats +++ b/acceptance.bats @@ -81,6 +81,56 @@ 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 "Pass with extra metadata fields" { + run bin/kubeconform fixtures/metadata_extra.yaml + [ "$status" -eq 0 ] +} + +@test "Fail extra metadata fields when strict" { + run bin/kubeconform -strict fixtures/metadata_extra.yaml + [ "$status" -eq 1 ] +} + @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..10d42079 --- /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_extra.yaml b/fixtures/metadata_extra.yaml new file mode 100644 index 00000000..bd960b45 --- /dev/null +++ b/fixtures/metadata_extra.yaml @@ -0,0 +1,9 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: some-values + namespace: my-namespace + annotation: + flub: annotation should be annotations +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..2f574ec8 --- /dev/null +++ b/pkg/registry/embeded.go @@ -0,0 +1,35 @@ +package registry + +import ( + "embed" +) + +//go:embed *.json +var content embed.FS + +type EmbeddedRegistry struct { + debug bool + strict bool +} + +// NewEmbeddedRegistry creates a new "registry", that will serve schemas from embedded resource +func NewEmbeddedRegistry(debug bool, strict bool) *EmbeddedRegistry { + return &EmbeddedRegistry{ + debug, strict, + } +} + +// DownloadSchema retrieves the schema from a file for the resource +func (r EmbeddedRegistry) DownloadSchema(resourceKind, resourceAPIVersion, k8sVersion string) (string, []byte, error) { + var fileName string + if r.strict { + fileName = resourceKind + "-strict.json" + } else { + fileName = resourceKind + ".json" + } + bytes, err := content.ReadFile(fileName) + if err != nil { + return resourceKind, nil, nil + } + return "embedded:" + resourceKind, bytes, nil +} diff --git a/pkg/registry/metadata-strict.json b/pkg/registry/metadata-strict.json new file mode 100644 index 00000000..d5664e23 --- /dev/null +++ b/pkg/registry/metadata-strict.json @@ -0,0 +1,110 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "apiVersion": { + "$ref": "#/$defs/PREFIXED" + }, + "kind": { + "$ref": "#/$defs/NAME" + }, + "metadata": { + "type": "object", + "additionalProperties": false, + "properties": { + "annotations": { + "type": "object", + "propertyNames": { + "$ref": "#/$defs/PREFIXED" + }, + "patternProperties": { + "^.+$": { + "type": "string", + "minLength": 1 + } + } + }, + "finalizers": { + "type": "array", + "items": { + "type": "string" + } + }, + "generateName": { + "$ref": "#/$defs/RFC-1123-prefix" + }, + "labels": { + "type": "object", + "propertyNames": { + "$ref": "#/$defs/PREFIXED" + }, + "patternProperties": { + "^.+$": { + "$ref": "#/$defs/NAME" + } + } + }, + "managedFields": { + "type": "array", + "items": { + "type": "object" + } + }, + "name": { + "$ref": "#/$defs/RFC-1123" + }, + "namespace": { + "$ref": "#/$defs/RFC-1123" + } + }, + "oneOf": [ + { + "required": [ + "name" + ] + }, + { + "required": [ + "generateName" + ] + } + ] + } + }, + "required": [ + "apiVersion", + "kind", + "metadata" + ], + "$defs": { + "PREFIXED": { + "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/metadata.json b/pkg/registry/metadata.json new file mode 100644 index 00000000..8f846378 --- /dev/null +++ b/pkg/registry/metadata.json @@ -0,0 +1,109 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "apiVersion": { + "$ref": "#/$defs/PREFIXED" + }, + "kind": { + "$ref": "#/$defs/NAME" + }, + "metadata": { + "type": "object", + "properties": { + "annotations": { + "type": "object", + "propertyNames": { + "$ref": "#/$defs/PREFIXED" + }, + "patternProperties": { + "^.+$": { + "type": "string", + "minLength": 1 + } + } + }, + "finalizers": { + "type": "array", + "items": { + "type": "string" + } + }, + "generateName": { + "$ref": "#/$defs/RFC-1123-prefix" + }, + "labels": { + "type": "object", + "propertyNames": { + "$ref": "#/$defs/PREFIXED" + }, + "patternProperties": { + "^.+$": { + "$ref": "#/$defs/NAME" + } + } + }, + "managedFields": { + "type": "array", + "items": { + "type": "object" + } + }, + "name": { + "$ref": "#/$defs/RFC-1123" + }, + "namespace": { + "$ref": "#/$defs/RFC-1123" + } + }, + "oneOf": [ + { + "required": [ + "name" + ] + }, + { + "required": [ + "generateName" + ] + } + ] + } + }, + "required": [ + "apiVersion", + "kind", + "metadata" + ], + "$defs": { + "PREFIXED": { + "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/validator/validator.go b/pkg/validator/validator.go index 0f8e85a7..f7b475eb 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 @@ -92,11 +93,17 @@ func New(schemaLocations []string, opts Opts) (Validator, error) { opts.RejectKinds = map[string]struct{}{} } + var metadataSchema *jsonschema.Schema = nil + if !opts.SkipMetadata { + metadataSchema, _ = downloadSchema([]registry.Registry{registry.NewEmbeddedRegistry(opts.Debug, opts.Strict)}, "metadata", "", "") + } + return &v{ opts: opts, schemaDownload: downloadSchema, schemaCache: cache.NewInMemoryCache(), regs: registries, + metadataSchema: metadataSchema, }, nil } @@ -105,6 +112,23 @@ type v struct { schemaCache cache.Cache schemaDownload func(registries []registry.Registry, kind, version, k8sVersion string) (*jsonschema.Schema, error) regs []registry.Registry + metadataSchema *jsonschema.Schema +} + +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 } // ValidateResource validates a single resource. This allows to validate @@ -162,6 +186,12 @@ func (val *v) ValidateResource(res resource.Resource) Result { return Result{Resource: res, Err: fmt.Errorf("prohibited resource kind %s", sig.Kind), Status: Error} } + validationErrors := []ValidationError{} + var metaDataError error = nil + if val.metadataSchema != nil { + validationErrors, metaDataError = validate(val.metadataSchema, r, validationErrors) + } + cached := false var schema *jsonschema.Schema @@ -183,27 +213,21 @@ func (val *v) ValidateResource(res resource.Resource) Result { } } + status := Valid if schema == nil { if val.opts.IgnoreMissingSchemas { - return Result{Resource: res, Err: nil, Status: Skipped} + status = Skipped + } else { + return Result{Resource: res, Err: fmt.Errorf("could not find schema for %s", sig.Kind), Status: Error} + } + } else { + validationErrors, err = validate(schema, r, validationErrors) + if err == nil { + err = metaDataError } - - 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, - }) - } - - } + if len(validationErrors) > 0 { return Result{ Resource: res, Status: Invalid, @@ -211,8 +235,7 @@ func (val *v) ValidateResource(res resource.Resource) Result { ValidationErrors: validationErrors, } } - - return Result{Resource: res, Status: Valid} + return Result{Resource: res, Status: status} } // ValidateWithContext validates resources found in r 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