From c90504a19d44f50ebe62c6ee71c062c9c02ae6fe Mon Sep 17 00:00:00 2001 From: Katrina Verey Date: Wed, 9 Feb 2022 16:46:56 -0500 Subject: [PATCH] Enable validation using function config schema from KRMFunctionDefinition --- cmd/pluginator/go.sum | 3 + kyaml/fn/framework/example/main.go | 2 +- kyaml/fn/framework/example_test.go | 72 +++++-- kyaml/fn/framework/framework.go | 17 ++ kyaml/fn/framework/function_definition.go | 91 +++++++++ kyaml/fn/framework/processors.go | 47 ++++- kyaml/fn/framework/processors_test.go | 192 ++++++++++++++++-- .../testdata/validation/error/errors.txt | 10 +- kyaml/fn/framework/validation.go | 36 ++++ kyaml/fn/framework/validation_test.go | 153 ++++++++++++++ 10 files changed, 580 insertions(+), 43 deletions(-) create mode 100644 kyaml/fn/framework/function_definition.go create mode 100644 kyaml/fn/framework/validation.go create mode 100644 kyaml/fn/framework/validation_test.go diff --git a/cmd/pluginator/go.sum b/cmd/pluginator/go.sum index 07db6d35ce..f3e0e137ee 100644 --- a/cmd/pluginator/go.sum +++ b/cmd/pluginator/go.sum @@ -48,6 +48,7 @@ github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kd github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o= github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY= github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= +github.com/asaskevich/govalidator v0.0.0-20190424111038-f61b66f89f4a h1:idn718Q4B6AGu/h5Sxe66HYVdqdGu2l9Iebqhi/AEoA= github.com/asaskevich/govalidator v0.0.0-20190424111038-f61b66f89f4a/go.mod h1:lB+ZfQJz7igIIfQNfa7Ml4HSf2uFQQRzpGGRXenZAgY= github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= github.com/bketelsen/crypt v0.0.4/go.mod h1:aI6NrJ0pMGgvZKL1iVgXLnfIFJtfV+bKCoqOes/6LfM= @@ -156,6 +157,7 @@ github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm4 github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.1.2 h1:EVhdT+1Kseyi1/pUmXKaFxYsDNy9RQYkMWRH68J/W7Y= github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= @@ -218,6 +220,7 @@ github.com/mitchellh/gox v0.4.0/go.mod h1:Sd9lOJ0+aimLBi73mGofS1ycjY8lL3uZM3JPS4 github.com/mitchellh/iochan v1.0.0/go.mod h1:JwYml1nuB7xOzsp52dPpHFffvOCDupsG0QubkSMEySY= github.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= +github.com/mitchellh/mapstructure v1.4.1 h1:CpVNEelQCZBooIPDn+AR3NpivK/TIKU8bDxdASFVQag= github.com/mitchellh/mapstructure v1.4.1/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= diff --git a/kyaml/fn/framework/example/main.go b/kyaml/fn/framework/example/main.go index 3288698dbe..56645c1f04 100644 --- a/kyaml/fn/framework/example/main.go +++ b/kyaml/fn/framework/example/main.go @@ -33,7 +33,7 @@ func buildProcessor(value *string) framework.ResourceListProcessor { }}, // This will be populated from the --value flag if provided, // or the config file's `value` field if provided, with the latter taking precedence. - TemplateData: struct { + TemplateData: &struct { Value *string `yaml:"value"` }{Value: value}} } diff --git a/kyaml/fn/framework/example_test.go b/kyaml/fn/framework/example_test.go index e2dd17b652..6c0766b5da 100644 --- a/kyaml/fn/framework/example_test.go +++ b/kyaml/fn/framework/example_test.go @@ -10,11 +10,14 @@ import ( "path/filepath" "strings" + validationErrors "k8s.io/kube-openapi/pkg/validation/errors" + "k8s.io/kube-openapi/pkg/validation/spec" "sigs.k8s.io/kustomize/kyaml/errors" "sigs.k8s.io/kustomize/kyaml/fn/framework" "sigs.k8s.io/kustomize/kyaml/fn/framework/command" "sigs.k8s.io/kustomize/kyaml/fn/framework/parser" "sigs.k8s.io/kustomize/kyaml/kio" + "sigs.k8s.io/kustomize/kyaml/resid" "sigs.k8s.io/kustomize/kyaml/yaml" ) @@ -962,28 +965,61 @@ func (a *v1alpha1JavaSpringBoot) Default() error { return nil } +var javaSpringBootDefinition = ` +apiVersion: config.kubernetes.io/v1alpha1 +kind: KRMFunctionDefinition +metadata: + name: javaspringboot.example.com +spec: + group: example.com + names: + kind: JavaSpringBoot + versions: + - name: v1alpha1 + schema: + openAPIV3Schema: + properties: + apiVersion: + type: string + kind: + type: string + metadata: + type: object + properties: + name: + type: string + minLength: 1 + required: + - name + spec: + properties: + domain: + pattern: example\.com$ + type: string + image: + type: string + replicas: + maximum: 9 + minimum: 0 + type: integer + type: object + type: object +` + +func (a v1alpha1JavaSpringBoot) Schema() (*spec.Schema, error) { + schema, err := framework.SchemaFromFunctionDefinition(resid.NewGvk("example.com", "v1alpha1", "JavaSpringBoot"), javaSpringBootDefinition) + return schema, errors.WrapPrefixf(err, "parsing JavaSpringBoot schema") +} + func (a *v1alpha1JavaSpringBoot) Validate() error { - var messages []string - if a.Metadata.Name == "" { - messages = append(messages, "name is required") - } - if a.Spec.Replicas > 10 { - messages = append(messages, "replicas must be less than 10") - } - if !strings.HasSuffix(a.Spec.Domain, "example.com") { - messages = append(messages, "domain must be a subdomain of example.com") - } + var errs []error if strings.HasSuffix(a.Spec.Image, ":latest") { - messages = append(messages, "image should not have latest tag") + errs = append(errs, errors.Errorf("spec.image should not have latest tag")) } - if len(messages) == 0 { - return nil + if len(errs) > 0 { + return validationErrors.CompositeValidationError(errs...) } - errMsg := fmt.Sprintf("JavaSpringBoot had %d errors:\n", len(messages)) - for i, msg := range messages { - errMsg += fmt.Sprintf(" [%d] %s\n", i+1, msg) - } - return errors.Errorf(errMsg) + return nil } // ExampleVersionedAPIProcessor shows how to use the VersionedAPIProcessor and TemplateProcessor to diff --git a/kyaml/fn/framework/framework.go b/kyaml/fn/framework/framework.go index 2f345eb3e0..39ff3e0fc5 100644 --- a/kyaml/fn/framework/framework.go +++ b/kyaml/fn/framework/framework.go @@ -7,6 +7,7 @@ import ( goerrors "errors" "os" + "k8s.io/kube-openapi/pkg/validation/spec" "sigs.k8s.io/kustomize/kyaml/errors" "sigs.k8s.io/kustomize/kyaml/kio" "sigs.k8s.io/kustomize/kyaml/yaml" @@ -92,6 +93,19 @@ type Validator interface { Validate() error } +// ValidationSchemaProvider is implemented by APIs to have the openapi schema provided by Schema() +// used to validate the input functionConfig before it is parsed into the API's struct. +// Use this with framework.SchemaFromFunctionDefinition to load the schema out of a KRMFunctionDefinition +// or CRD (e.g. one generated with KubeBuilder). +// +// func (t MyType) Schema() (*spec.Schema, error) { +// schema, err := framework.SchemaFromFunctionDefinition(resid.NewGvk("example.com", "v1", "MyType"), MyTypeDef) +// return schema, errors.WrapPrefixf(err, "parsing MyType schema") +// } +type ValidationSchemaProvider interface { + Schema() (*spec.Schema, error) +} + // Execute is the entrypoint for invoking configuration functions built with this framework // from code. See framework/command#Build for a Cobra-based command-line equivalent. // Execute reads a ResourceList from the given source, passes it to a ResourceListProcessor, @@ -158,6 +172,9 @@ func Execute(p ResourceListProcessor, rlSource *kio.ByteReadWriter) error { // Filters that return a Result as error will store the result in the ResourceList // and continue processing instead of erroring out. func (rl *ResourceList) Filter(api kio.Filter) error { + if api == nil { + return errors.Errorf("ResourceList cannot run apply nil filter") + } var err error rl.Items, err = api.Filter(rl.Items) if err != nil { diff --git a/kyaml/fn/framework/function_definition.go b/kyaml/fn/framework/function_definition.go new file mode 100644 index 0000000000..8bbdf89a0a --- /dev/null +++ b/kyaml/fn/framework/function_definition.go @@ -0,0 +1,91 @@ +// Copyright 2022 The Kubernetes Authors. +// SPDX-License-Identifier: Apache-2.0 + +package framework + +import ( + "k8s.io/kube-openapi/pkg/validation/spec" + "sigs.k8s.io/kustomize/kyaml/fn/runtime/runtimeutil" + "sigs.k8s.io/kustomize/kyaml/yaml" +) + +const FunctionDefinitionKind = "KRMFunctionDefinition" +const FunctionDefinitionGroupVersion = "config.kubernetes.io/v1alpha1" + +// KRMFunctionDefinition is metadata that defines a KRM function the same way a CRD defines a custom resource. +// https://github.com/kubernetes/enhancements/tree/master/keps/sig-cli/2906-kustomize-function-catalog#function-metadata-schema +type KRMFunctionDefinition struct { + // APIVersion and Kind of the object. Must be config.kubernetes.io/v1alpha1 and KRMFunctionDefinition respectively. + yaml.TypeMeta `yaml:",inline" json:",inline"` + // Standard KRM object metadata + yaml.ObjectMeta `yaml:"metadata,omitempty" json:"metadata,omitempty"` + // Spec contains the properties of the KRM function this object defines. + Spec KrmFunctionDefinitionSpec `yaml:"spec" json:"spec"` +} + +type KrmFunctionDefinitionSpec struct { + // + // The following fields are shared with CustomResourceDefinition. + // + // Group is the API group of the defined KRM function. + Group string `yaml:"group" json:"group"` + // Names specify the resource and kind names for the KRM function. + Names KRMFunctionNames `yaml:"names" json:"names"` + // Versions is the list of all API versions of the defined KRM function. + Versions []KRMFunctionVersion `yaml:"versions" json:"versions"` + + // + // The following fields are custom to KRMFunctionDefinition + // + // Description briefly describes the KRM function. + Description string `yaml:"description,omitempty" json:"description,omitempty"` + // Publisher is the entity (e.g. organization) that produced and owns this KRM function. + Publisher string `yaml:"publisher,omitempty" json:"publisher,omitempty"` + // Home is a URI pointing the home page of the KRM function. + Home string `yaml:"home,omitempty" json:"home,omitempty"` + // Maintainers lists the individual maintainers of the KRM function. + Maintainers []string `yaml:"maintainers,omitempty" json:"maintainers,omitempty"` + // Tags are keywords describing the function. e.g. mutator, validator, generator, prefix, GCP. + Tags []string `yaml:"tags,omitempty" json:"tags,omitempty"` +} + +type KRMFunctionVersion struct { + // + // The following fields are shared with CustomResourceDefinition. + // + // Name is the version name, e.g. “v1”, “v2beta1”, etc. + Name string `yaml:"name" json:"name"` + // Schema describes the schema of this version of the KRM function. + // This can be used for validation, pruning, and/or defaulting. + Schema *KRMFunctionValidation `yaml:"schema,omitempty" json:"schema,omitempty"` + + // + // The following fields are custom to KRMFunctionDefinition + // + // Idempotent indicates whether the function can be re-run multiple times without changing the result. + Idempotent bool `yaml:"idempotent,omitempty" json:"idempotent,omitempty"` + // Usage is URI pointing to a README.md that describe the details of how to use the KRM function. + // It should at least cover what the function does and should give a detailed explanation about each + // field used to configure it. + Usage string `yaml:"usage,omitempty" json:"usage,omitempty"` + // A list of URIs that point to README.md files. Each README.md should cover an example. + // It should at least cover how to get input resources, how to run it and what is the expected + // output. + Examples []string `yaml:"examples,omitempty" json:"examples,omitempty"` + // License is the name of the license covering the function. + License string `yaml:"license,omitempty" json:"license,omitempty"` + // The maintainers for this version of the function, if different from the primary maintainers. + Maintainers []string `yaml:"maintainers,omitempty" json:"maintainers,omitempty"` + // The runtime information describing how to execute this function. + Runtime runtimeutil.FunctionSpec `yaml:"runtime" json:"runtime"` +} + +type KRMFunctionValidation struct { + // OpenAPIV3Schema is the OpenAPI v3 schema for an instance of the KRM function. + OpenAPIV3Schema *spec.Schema `yaml:"openAPIV3Schema,omitempty" json:"openAPIV3Schema,omitempty"` +} + +type KRMFunctionNames struct { + // Kind is the kind of the defined KRM Function. It is normally CamelCase and singular. + Kind string `yaml:"kind" json:"kind"` +} diff --git a/kyaml/fn/framework/processors.go b/kyaml/fn/framework/processors.go index eedff8ffe7..6dfc9a98e7 100644 --- a/kyaml/fn/framework/processors.go +++ b/kyaml/fn/framework/processors.go @@ -6,12 +6,16 @@ package framework import ( "strings" + validationErrors "k8s.io/kube-openapi/pkg/validation/errors" "k8s.io/kube-openapi/pkg/validation/spec" + "k8s.io/kube-openapi/pkg/validation/strfmt" + "k8s.io/kube-openapi/pkg/validation/validate" "sigs.k8s.io/kustomize/kyaml/errors" "sigs.k8s.io/kustomize/kyaml/kio" "sigs.k8s.io/kustomize/kyaml/kio/filters" "sigs.k8s.io/kustomize/kyaml/openapi" "sigs.k8s.io/kustomize/kyaml/yaml" + k8syaml "sigs.k8s.io/yaml" ) // SimpleProcessor processes a ResourceList by loading the FunctionConfig into @@ -35,9 +39,9 @@ type SimpleProcessor struct { // defaulting and validation if supported by Config. It then executes the processor's filter. func (p SimpleProcessor) Process(rl *ResourceList) error { if err := LoadFunctionConfig(rl.FunctionConfig, p.Config); err != nil { - return errors.Wrap(err) + return errors.WrapPrefixf(err, "loading function config") } - return errors.Wrap(rl.Filter(p.Filter)) + return errors.WrapPrefixf(rl.Filter(p.Filter), "processing filter") } // GVKFilterMap is a FilterProvider that resolves Filters through a simple lookup in a map. @@ -139,7 +143,24 @@ func LoadFunctionConfig(src *yaml.RNode, api interface{}) error { if api == nil { return nil } - if err := yaml.Unmarshal([]byte(src.MustString()), api); err != nil { + // Run this before unmarshalling to avoid nasty unmarshal failure error messages + var schemaValidationError error + if s, ok := api.(ValidationSchemaProvider); ok { + schema, err := s.Schema() + if err != nil { + return errors.WrapPrefixf(err, "loading provided schema") + } + schemaValidationError = validate.AgainstSchema(schema, src, strfmt.Default) + // don't return it yet--try to make it to custom validation stage to combine errors + } + + // using sigs.k8s.io/yaml here lets the custom types embed core types + // that only have json tags, notably types from k8s.io/apimachinery/pkg/apis/meta/v1 + if err := k8syaml.Unmarshal([]byte(src.MustString()), api); err != nil { + if schemaValidationError != nil { + // if we got a validation error, report it instead as it is likely a nicer version of the same message + return schemaValidationError + } return errors.Wrap(err) } @@ -150,7 +171,25 @@ func LoadFunctionConfig(src *yaml.RNode, api interface{}) error { } if v, ok := api.(Validator); ok { - return v.Validate() + return combineErrors(schemaValidationError, v.Validate()) + } + return nil +} + +func combineErrors(schemaErr, customErr error) error { + combined := validationErrors.CompositeValidationError() + if compositeSchemaErr, ok := schemaErr.(*validationErrors.CompositeError); ok { + combined.Errors = append(combined.Errors, compositeSchemaErr.Errors...) + } else if schemaErr != nil { + combined.Errors = append(combined.Errors, schemaErr) + } + if compositeCustomErr, ok := customErr.(*validationErrors.CompositeError); ok { + combined.Errors = append(combined.Errors, compositeCustomErr.Errors...) + } else if customErr != nil { + combined.Errors = append(combined.Errors, customErr) + } + if len(combined.Errors) > 0 { + return combined } return nil } diff --git a/kyaml/fn/framework/processors_test.go b/kyaml/fn/framework/processors_test.go index 82506a47f6..da2964380a 100644 --- a/kyaml/fn/framework/processors_test.go +++ b/kyaml/fn/framework/processors_test.go @@ -11,6 +11,8 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + validationErrors "k8s.io/kube-openapi/pkg/validation/errors" + "k8s.io/kube-openapi/pkg/validation/spec" "sigs.k8s.io/kustomize/kyaml/errors" "sigs.k8s.io/kustomize/kyaml/fn/framework/frameworktestutil" "sigs.k8s.io/kustomize/kyaml/fn/framework/parser" @@ -357,13 +359,21 @@ func TestSimpleProcessor_Process_Error(t *testing.T) { wantErr string }{ { - name: "error when given func as Config", - config: func() {}, - wantErr: "cannot unmarshal !!map into func()", + name: "error when filter is nil", + config: map[string]string{}, + filter: nil, + wantErr: "processing filter: ResourceList cannot run apply nil filter", + }, { + name: "no error when config is nil", + config: nil, + filter: kio.FilterFunc(func(items []*yaml.RNode) ([]*yaml.RNode, error) { + return items, nil + }), + wantErr: "", }, { name: "error in filter", - wantErr: "err from filter", + wantErr: "processing filter: err from filter", filter: kio.FilterFunc(func(_ []*yaml.RNode) ([]*yaml.RNode, error) { return nil, errors.Errorf("err from filter") }), @@ -382,8 +392,11 @@ func TestSimpleProcessor_Process_Error(t *testing.T) { }), } err := p.Process(&rl) - require.Error(t, err) - assert.Contains(t, err.Error(), tt.wantErr) + if tt.wantErr == "" { + require.NoError(t, err) + } else { + assert.EqualError(t, err, tt.wantErr) + } }) } } @@ -396,15 +409,6 @@ func TestVersionedAPIProcessor_Process_Error(t *testing.T) { kind string wantErr string }{ - { - name: "error when given FilterFunc as Filter", - filterProvider: framework.FilterProviderFunc(func(_, _ string) (kio.Filter, error) { - return kio.FilterFunc(func(items []*yaml.RNode) ([]*yaml.RNode, error) { - return items, nil - }), nil - }), - wantErr: "cannot unmarshal !!map into kio.FilterFunc", - }, { name: "error in filter", filterProvider: framework.FilterProviderFunc(func(_, _ string) (kio.Filter, error) { @@ -658,3 +662,161 @@ func TestTemplateProcessor_Validator(t *testing.T) { } c.Assert(t) } + +type jsonTagTest struct { + Name string `json:"name"` + Test bool `json:"test"` +} + +type yamlTagTest struct { + Name string `yaml:"name"` + Test bool `yaml:"test"` +} + +type customErrorTest struct { + v1alpha1JavaSpringBoot +} + +func (e customErrorTest) Schema() (*spec.Schema, error) { + return e.v1alpha1JavaSpringBoot.Schema() +} + +func (e customErrorTest) Validate() error { + return errors.Errorf("Custom errors:\n- first error\n- second error") +} + +type errorMergeTest struct { + v1alpha1JavaSpringBoot +} + +func (e errorMergeTest) Schema() (*spec.Schema, error) { + return e.v1alpha1JavaSpringBoot.Schema() +} + +func (e errorMergeTest) Validate() error { + if strings.HasSuffix(e.Spec.Image, "latest") { + return validationErrors.CompositeValidationError(errors.Errorf("spec.image cannot be tagged :latest")) + } + return nil +} + +func TestLoadFunctionConfig(t *testing.T) { + tests := []struct { + name string + src *yaml.RNode + api interface{} + want interface{} + wantErrMsgs []string + }{ + { + name: "combines schema-based and non-composite custom errors", + src: yaml.MustParse(` +apiVersion: example.com/v1alpha1 +kind: JavaSpringBoot +spec: + replicas: 11 + domain: foo.myco.io + image: nginx:latest +`), + api: &customErrorTest{}, + wantErrMsgs: []string{ + "validation failure list:", + "spec.replicas in body should be less than or equal to 9", + "spec.domain in body should match 'example\\.com$'", + `Custom errors: +- first error +- second error`, + }, + }, + { + name: "merges schema-based errors with custom composite errors", + src: yaml.MustParse(` +apiVersion: example.com/v1alpha1 +kind: JavaSpringBoot +spec: + replicas: 11 + domain: foo.myco.io + image: nginx:latest +`), + api: &errorMergeTest{}, + wantErrMsgs: []string{"validation failure list:", + "spec.replicas in body should be less than or equal to 9", + "spec.domain in body should match 'example\\.com$'", + "spec.image cannot be tagged :latest"}, + }, + { + name: "schema errors only", + src: yaml.MustParse(` +apiVersion: example.com/v1alpha1 +kind: JavaSpringBoot +spec: + replicas: 11 +`), + api: &errorMergeTest{}, + wantErrMsgs: []string{ + `validation failure list: +spec.replicas in body should be less than or equal to 9`, + }, + }, + { + name: "custom errors only", + src: yaml.MustParse(` +apiVersion: example.com/v1alpha1 +kind: JavaSpringBoot +spec: + image: nginx:latest +`), + api: &errorMergeTest{}, + wantErrMsgs: []string{ + `validation failure list: +spec.image cannot be tagged :latest`}, + }, + { + name: "both custom and schema error hooks defined, but no errors produced", + src: yaml.MustParse(` +apiVersion: example.com/v1alpha1 +kind: JavaSpringBoot +spec: + image: nginx:1.0 + replicas: 3 + domain: bar.example.com +`), + api: &errorMergeTest{}, + want: &errorMergeTest{v1alpha1JavaSpringBoot: v1alpha1JavaSpringBoot{ + Spec: v1alpha1JavaSpringBootSpec{Replicas: 3, Domain: "bar.example.com", Image: "nginx:1.0"}}, + }, + }, + { + name: "successfully loads types that include fields only tagged with json markers", + src: yaml.MustParse(` +name: tester +test: true +`), + api: &jsonTagTest{}, + want: &jsonTagTest{Name: "tester", Test: true}, + }, + { + name: "successfully loads types that include fields only tagged with yaml markers", + src: yaml.MustParse(` +name: tester +test: true +`), + api: &yamlTagTest{}, + want: &yamlTagTest{Name: "tester", Test: true}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := framework.LoadFunctionConfig(tt.src, tt.api) + if len(tt.wantErrMsgs) == 0 { + require.NoError(t, err) + require.Equal(t, tt.want, tt.api) + } else { + for _, msg := range tt.wantErrMsgs { + require.Contains(t, err.Error(), msg) + } + } + }) + } +} diff --git a/kyaml/fn/framework/testdata/validation/error/errors.txt b/kyaml/fn/framework/testdata/validation/error/errors.txt index 92f3c6a51b..8086571ba1 100644 --- a/kyaml/fn/framework/testdata/validation/error/errors.txt +++ b/kyaml/fn/framework/testdata/validation/error/errors.txt @@ -1,5 +1,5 @@ -JavaSpringBoot had 4 errors: -\[\d\] replicas must be less than 10 -\[\d\] name is required -\[\d\] image should not have latest tag -\[\d\] domain must be a subdomain of example.com +validation failure list: +spec.domain in body should match 'example\\.com\$' +spec.image should not have latest tag +metadata.name in body should be at least 1 chars long +spec.replicas in body should be less than or equal to 9 diff --git a/kyaml/fn/framework/validation.go b/kyaml/fn/framework/validation.go new file mode 100644 index 0000000000..c6812cfef0 --- /dev/null +++ b/kyaml/fn/framework/validation.go @@ -0,0 +1,36 @@ +// Copyright 2022 The Kubernetes Authors. +// SPDX-License-Identifier: Apache-2.0 + +package framework + +import ( + "k8s.io/kube-openapi/pkg/validation/spec" + "sigs.k8s.io/kustomize/kyaml/errors" + "sigs.k8s.io/kustomize/kyaml/resid" + k8syaml "sigs.k8s.io/yaml" +) + +// SchemaFromFunctionDefinition extracts the schema for a particular GVK from the provided KRMFunctionDefinition +// Since the relevant fields of KRMFunctionDefinition exactly match the ones in CustomResourceDefinition, +// this helper can also load CRDs (e.g. produced by KubeBuilder) transparently. +func SchemaFromFunctionDefinition(gvk resid.Gvk, data string) (*spec.Schema, error) { + var def KRMFunctionDefinition + // need to use sigs yaml because spec.Schema type only has json tags + if err := k8syaml.Unmarshal([]byte(data), &def); err != nil { + return nil, errors.WrapPrefixf(err, "unmarshalling %s", FunctionDefinitionKind) + } + var foundGVKs []*resid.Gvk + var schema *spec.Schema + for i, version := range def.Spec.Versions { + versionGVK := resid.Gvk{Group: def.Spec.Group, Kind: def.Spec.Names.Kind, Version: version.Name} + if gvk.Equals(versionGVK) { + schema = def.Spec.Versions[i].Schema.OpenAPIV3Schema + break + } + foundGVKs = append(foundGVKs, &versionGVK) + } + if schema == nil { + return nil, errors.Errorf("%s does not define %s (defines: %s)", FunctionDefinitionKind, gvk, foundGVKs) + } + return schema, nil +} diff --git a/kyaml/fn/framework/validation_test.go b/kyaml/fn/framework/validation_test.go new file mode 100644 index 0000000000..c7a4a40536 --- /dev/null +++ b/kyaml/fn/framework/validation_test.go @@ -0,0 +1,153 @@ +// Copyright 2022 The Kubernetes Authors. +// SPDX-License-Identifier: Apache-2.0 + +package framework + +import ( + "sort" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "sigs.k8s.io/kustomize/kyaml/resid" +) + +var demoFunctionDefinition = ` +apiVersion: config.kubernetes.io/v1alpha1 +kind: KRMFunctionDefinition +metadata: + name: demos.example.io +spec: + group: example.io + names: + kind: Demo + versions: + - name: v1alpha1 + schema: + openAPIV3Schema: + properties: + apiVersion: + type: string + color: + type: string + kind: + type: string + metadata: + type: object + required: + - color + type: object + - name: v1alpha2 + schema: + openAPIV3Schema: + properties: + apiVersion: + type: string + flavor: + type: string + kind: + type: string + metadata: + type: object + required: + - flavor + type: object +` + +var demoCRD = ` +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: demos.example.io +spec: + group: example.io + names: + kind: Demo + listKind: DemoList + plural: demos + singular: demo + scope: Namespaced + versions: + - name: v1alpha1 + schema: + openAPIV3Schema: + properties: + apiVersion: + description: 'APIVersion defines the versioned schema of this representation + of an object. Servers should convert recognized schemas to the latest + internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources' + type: string + color: + type: string + kind: + description: 'Kind is a string value representing the REST resource this + object represents. Servers may infer this from the endpoint the client + submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds' + type: string + metadata: + type: object + required: + - color + type: object + served: true + storage: true +` + +func TestSchemaFromFunctionDefinition(t *testing.T) { + tests := []struct { + name string + gvk resid.Gvk + data string + wantProps []string + wantErr string + }{ + { + name: "demo KRMFunctionDefinition extract v1alpha1", + gvk: resid.NewGvk("example.io", "v1alpha1", "Demo"), + data: demoFunctionDefinition, + wantProps: []string{"apiVersion", "kind", "metadata", "color"}, + }, { + name: "demo KRMFunctionDefinition extract v1alpha2", + gvk: resid.NewGvk("example.io", "v1alpha2", "Demo"), + data: demoFunctionDefinition, + wantProps: []string{"apiVersion", "kind", "metadata", "flavor"}, + }, { + name: "works with CustomResourceDefinition", + gvk: resid.NewGvk("example.io", "v1alpha1", "Demo"), + data: demoCRD, + wantProps: []string{"apiVersion", "kind", "metadata", "color"}, + }, { + name: "group mismatch", + gvk: resid.NewGvk("example.com", "v1alpha2", "Demo"), + data: demoFunctionDefinition, + wantErr: "KRMFunctionDefinition does not define Demo.v1alpha2.example.com (defines: [Demo.v1alpha1.example.io Demo.v1alpha2.example.io])", + }, { + name: "version mismatch", + gvk: resid.NewGvk("example.io", "v1alpha3", "Demo"), + data: demoFunctionDefinition, + wantErr: "KRMFunctionDefinition does not define Demo.v1alpha3.example.io (defines: [Demo.v1alpha1.example.io Demo.v1alpha2.example.io])", + }, { + name: "kind mismatch", + gvk: resid.NewGvk("example.io", "v1alpha2", "Demonstration"), + data: demoFunctionDefinition, + wantErr: "KRMFunctionDefinition does not define Demonstration.v1alpha2.example.io (defines: [Demo.v1alpha1.example.io Demo.v1alpha2.example.io])", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := SchemaFromFunctionDefinition(tt.gvk, tt.data) + if tt.wantErr != "" { + require.EqualError(t, err, tt.wantErr) + } else { + require.NoError(t, err) + var gotProps []string + for prop, _ := range got.Properties { + gotProps = append(gotProps, prop) + } + sort.Strings(tt.wantProps) + sort.Strings(gotProps) + assert.Equal(t, gotProps, tt.wantProps) + } + }) + } +}