validate ObjectMeta

closes #287, #275, #286
This commit is contained in:
Chas Honton 2024-08-26 21:01:01 -07:00 committed by Charles Honton
parent 1bd44986dd
commit 77c022cfa1
19 changed files with 430 additions and 22 deletions

View file

@ -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 ]

View file

@ -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,
})

View file

@ -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"

View file

@ -0,0 +1,8 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: some-values
annotations:
some.domain/some-key:
data:
file.name: "a value"

View file

@ -0,0 +1,8 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: some-values
annotations:
some.domain/some-key: null
data:
file.name: "a value"

View file

@ -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"

View file

@ -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"

View file

@ -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"

View file

@ -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"

View file

@ -0,0 +1,4 @@
apiVersion: v1
kind: ConfigMap
data:
file.name: "a value"

View file

@ -0,0 +1,6 @@
apiVersion: v1
kind: ConfigMap
metadata:
data:
file.name: "a value"

View file

@ -0,0 +1,6 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: abcdefghijklmnopqrstuvwxyz-01234567890-ABCDEFGHIJKLMNOPQRSTUVWXYZ
data:
file.name: "a value"

View file

@ -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")

35
pkg/registry/embeded.go Normal file
View file

@ -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
}

View file

@ -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-]*$"
}
}
}

109
pkg/registry/metadata.json Normal file
View file

@ -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-]*$"
}
}
}

View file

@ -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

View file

@ -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,

View file

@ -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