diff --git a/pkg/validation/schema.go b/pkg/validation/schema.go new file mode 100644 index 000000000..6eef61939 --- /dev/null +++ b/pkg/validation/schema.go @@ -0,0 +1,103 @@ +/* +Copyright 2014 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package validation + +import ( + "bytes" + "encoding/json" + "fmt" + + ejson "github.com/exponent-io/jsonpath" + utilerrors "k8s.io/apimachinery/pkg/util/errors" +) + +// Schema is an interface that knows how to validate an API object serialized to a byte array. +type Schema interface { + ValidateBytes(data []byte) error +} + +// NullSchema always validates bytes. +type NullSchema struct{} + +// ValidateBytes never fails for NullSchema. +func (NullSchema) ValidateBytes(data []byte) error { return nil } + +// NoDoubleKeySchema is a schema that disallows double keys. +type NoDoubleKeySchema struct{} + +// ValidateBytes validates bytes. +func (NoDoubleKeySchema) ValidateBytes(data []byte) error { + var list []error + if err := validateNoDuplicateKeys(data, "metadata", "labels"); err != nil { + list = append(list, err) + } + if err := validateNoDuplicateKeys(data, "metadata", "annotations"); err != nil { + list = append(list, err) + } + return utilerrors.NewAggregate(list) +} + +func validateNoDuplicateKeys(data []byte, path ...string) error { + r := ejson.NewDecoder(bytes.NewReader(data)) + // This is Go being unfriendly. The 'path ...string' comes in as a + // []string, and SeekTo takes ...interface{}, so we can't just pass + // the path straight in, we have to copy it. *sigh* + ifacePath := []interface{}{} + for ix := range path { + ifacePath = append(ifacePath, path[ix]) + } + found, err := r.SeekTo(ifacePath...) + if err != nil { + return err + } + if !found { + return nil + } + seen := map[string]bool{} + for { + tok, err := r.Token() + if err != nil { + return err + } + switch t := tok.(type) { + case json.Delim: + if t.String() == "}" { + return nil + } + case ejson.KeyString: + if seen[string(t)] { + return fmt.Errorf("duplicate key: %s", string(t)) + } + seen[string(t)] = true + } + } +} + +// ConjunctiveSchema encapsulates a schema list. +type ConjunctiveSchema []Schema + +// ValidateBytes validates bytes per a ConjunctiveSchema. +func (c ConjunctiveSchema) ValidateBytes(data []byte) error { + var list []error + schemas := []Schema(c) + for ix := range schemas { + if err := schemas[ix].ValidateBytes(data); err != nil { + list = append(list, err) + } + } + return utilerrors.NewAggregate(list) +} diff --git a/pkg/validation/schema_test.go b/pkg/validation/schema_test.go new file mode 100644 index 000000000..c0ac76463 --- /dev/null +++ b/pkg/validation/schema_test.go @@ -0,0 +1,141 @@ +/* +Copyright 2014 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package validation + +import ( + "fmt" + "testing" +) + +func TestValidateDuplicateLabelsFailCases(t *testing.T) { + strs := []string{ + `{ + "metadata": { + "labels": { + "foo": "bar", + "foo": "baz" + } + } +}`, + `{ + "metadata": { + "annotations": { + "foo": "bar", + "foo": "baz" + } + } +}`, + `{ + "metadata": { + "labels": { + "foo": "blah" + }, + "annotations": { + "foo": "bar", + "foo": "baz" + } + } +}`, + } + schema := NoDoubleKeySchema{} + for _, str := range strs { + err := schema.ValidateBytes([]byte(str)) + if err == nil { + t.Errorf("Unexpected non-error %s", str) + } + } +} + +func TestValidateDuplicateLabelsPassCases(t *testing.T) { + strs := []string{ + `{ + "metadata": { + "labels": { + "foo": "bar" + }, + "annotations": { + "foo": "baz" + } + } +}`, + `{ + "metadata": {} +}`, + `{ + "metadata": { + "labels": {} + } +}`, + } + schema := NoDoubleKeySchema{} + for _, str := range strs { + err := schema.ValidateBytes([]byte(str)) + if err != nil { + t.Errorf("Unexpected error: %v %s", err, str) + } + } +} + +// AlwaysInvalidSchema is always invalid. +type AlwaysInvalidSchema struct{} + +// ValidateBytes always fails to validate. +func (AlwaysInvalidSchema) ValidateBytes([]byte) error { + return fmt.Errorf("always invalid") +} + +func TestConjunctiveSchema(t *testing.T) { + tests := []struct { + schemas []Schema + shouldPass bool + name string + }{ + { + schemas: []Schema{NullSchema{}, NullSchema{}}, + shouldPass: true, + name: "all pass", + }, + { + schemas: []Schema{NullSchema{}, AlwaysInvalidSchema{}}, + shouldPass: false, + name: "one fail", + }, + { + schemas: []Schema{AlwaysInvalidSchema{}, AlwaysInvalidSchema{}}, + shouldPass: false, + name: "all fail", + }, + { + schemas: []Schema{}, + shouldPass: true, + name: "empty", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + schema := ConjunctiveSchema(tt.schemas) + err := schema.ValidateBytes([]byte{}) + if err != nil && tt.shouldPass { + t.Errorf("Unexpected error: %v in %s", err, tt.name) + } + if err == nil && !tt.shouldPass { + t.Errorf("Unexpected non-error: %s", tt.name) + } + }) + } +} diff --git a/pkg/validation/testdata/v1/invalidPod.yaml b/pkg/validation/testdata/v1/invalidPod.yaml new file mode 100644 index 000000000..9557c55ff --- /dev/null +++ b/pkg/validation/testdata/v1/invalidPod.yaml @@ -0,0 +1,11 @@ +apiVersion: v1 +kind: Pod +metadata: + labels: + name: redis-master + name: name +spec: + containers: + - args: "this is a bad command" + image: gcr.io/fake_project/fake_image:fake_tag + name: master diff --git a/pkg/validation/testdata/v1/invalidPod1.json b/pkg/validation/testdata/v1/invalidPod1.json new file mode 100644 index 000000000..384d18579 --- /dev/null +++ b/pkg/validation/testdata/v1/invalidPod1.json @@ -0,0 +1,19 @@ +{ + "kind": "Pod", + "apiVersion": "v1", + "metadata": { + "name": "name", + "labels": { + "name": "redis-master" + } + }, + "spec": { + "containers": [ + { + "name": "master", + "image": "gcr.io/fake_project/fake_image:fake_tag", + "args": "this is a bad command" + } + ] + } +} diff --git a/pkg/validation/testdata/v1/invalidPod2.json b/pkg/validation/testdata/v1/invalidPod2.json new file mode 100644 index 000000000..56e8f93ba --- /dev/null +++ b/pkg/validation/testdata/v1/invalidPod2.json @@ -0,0 +1,35 @@ +{ + "kind": "Pod", + "apiVersion": "v1", + "metadata": { + "name": "apache-php", + "labels": { + "name": "apache-php" + } + }, + "spec": { + "volumes": [{ + "name": "shared-disk" + }], + "containers": [ + { + "name": "apache-php", + "image": "gcr.io/fake_project/fake_image:fake_tag", + "ports": [ + { + "name": "apache", + "hostPort": "13380", + "containerPort": 80, + "protocol": "TCP" + } + ], + "volumeMounts": [ + { + "name": "shared-disk", + "mountPath": "/var/www/html" + } + ] + } + ] + } +} diff --git a/pkg/validation/testdata/v1/invalidPod3.json b/pkg/validation/testdata/v1/invalidPod3.json new file mode 100644 index 000000000..69e0e8538 --- /dev/null +++ b/pkg/validation/testdata/v1/invalidPod3.json @@ -0,0 +1,35 @@ +{ + "kind": "Pod", + "apiVersion": "v1", + "metadata": { + "name": "apache-php", + "labels": { + "name": "apache-php" + } + }, + "spec": { + "volumes": [ + "name": "shared-disk" + ], + "containers": [ + { + "name": "apache-php", + "image": "gcr.io/fake_project/fake_image:fake_tag", + "ports": [ + { + "name": "apache", + "hostPort": 13380, + "containerPort": 80, + "protocol": "TCP" + } + ], + "volumeMounts": [ + { + "name": "shared-disk", + "mountPath": "/var/www/html" + } + ] + } + ] + } +} diff --git a/pkg/validation/testdata/v1/invalidPod4.yaml b/pkg/validation/testdata/v1/invalidPod4.yaml new file mode 100644 index 000000000..a6958db5e --- /dev/null +++ b/pkg/validation/testdata/v1/invalidPod4.yaml @@ -0,0 +1,14 @@ +apiVersion: v1 +kind: Pod +metadata: + labels: + name: redis-master + name: name +spec: + containers: + - image: gcr.io/fake_project/fake_image:fake_tag + name: master + args: + - + command: + - diff --git a/pkg/validation/testdata/v1/validPod.yaml b/pkg/validation/testdata/v1/validPod.yaml new file mode 100644 index 000000000..3849ba7a1 --- /dev/null +++ b/pkg/validation/testdata/v1/validPod.yaml @@ -0,0 +1,16 @@ +apiVersion: v1 +kind: Pod +metadata: + labels: + name: redis-master + name: name +spec: + containers: + - args: + - this + - is + - an + - ok + - command + image: gcr.io/fake_project/fake_image:fake_tag + name: master