From f424282da438b0db54098a75c3b27aa3353fd170 Mon Sep 17 00:00:00 2001 From: Ian Howell Date: Mon, 12 Aug 2019 16:21:39 -0500 Subject: [PATCH] Add the --additional-schema-locations flag This adds an option to kubeval's configuration that allows the use of additional base URLs to search for schemas. The tool will still prefer the default repositories, but if a schema cannot be found for a given YAML document (e.g. a CRD), kubeval will fallback on the secondary URLs. This implies that schemas must have been pre-generated and hosted at the secondary URLs. --- kubeval/config.go | 6 ++ kubeval/kubeval.go | 86 ++++++++++++++------- kubeval/kubeval_test.go | 164 ++++++++++++++++++++++++++++------------ 3 files changed, 180 insertions(+), 76 deletions(-) diff --git a/kubeval/config.go b/kubeval/config.go index 204d809..eefde76 100644 --- a/kubeval/config.go +++ b/kubeval/config.go @@ -22,6 +22,11 @@ type Config struct { // It can be either a remote location or a local directory SchemaLocation string + // AdditionalSchemaLocations is a list of alternative base URLs from + // which to search for schemas, given that the desired schema was not + // found at SchemaLocation + AdditionalSchemaLocations []string + // OpenShift represents whether to test against // upstream Kubernetes or the OpenShift schemas OpenShift bool @@ -67,6 +72,7 @@ func AddKubevalFlags(cmd *cobra.Command, config *Config) *cobra.Command { cmd.Flags().StringVarP(&config.FileName, "filename", "f", "stdin", "filename to be displayed when testing manifests read from stdin") cmd.Flags().StringSliceVar(&config.KindsToSkip, "skip-kinds", []string{}, "Comma-separated list of case-sensitive kinds to skip when validating against schemas") cmd.Flags().StringVarP(&config.SchemaLocation, "schema-location", "s", "", "Base URL used to download schemas. Can also be specified with the environment variable KUBEVAL_SCHEMA_LOCATION.") + cmd.Flags().StringSliceVar(&config.AdditionalSchemaLocations , "additional-schema-locations", []string{}, "Comma-seperated list of secondary base URLs used to download schemas") cmd.Flags().StringVarP(&config.KubernetesVersion, "kubernetes-version", "v", "master", "Version of Kubernetes to validate against") cmd.Flags().StringVarP(&config.OutputFormat, "output", "o", "", fmt.Sprintf("The format of the output of this script. Options are: %v", validOutputs())) diff --git a/kubeval/kubeval.go b/kubeval/kubeval.go index 81173bb..d5a4d17 100644 --- a/kubeval/kubeval.go +++ b/kubeval/kubeval.go @@ -34,10 +34,15 @@ type ValidationResult struct { Errors []gojsonschema.ResultError } -func determineSchema(kind, apiVersion string, config *Config) string { +// VersionKind returns a string representation of this result's apiVersion and kind +func (v *ValidationResult) VersionKind() string { + return v.APIVersion + "/" + v.Kind +} + +func determineSchemaURL(baseURL, kind, apiVersion string, config *Config) string { // We have both the upstream Kubernetes schemas and the OpenShift schemas available - // the tool can toggle between then using the config.Openshift boolean flag and here we - // use that to select which repository to get the schema from + // the tool can toggle between then using the config.OpenShift boolean flag and here we + // use that to format the URL to match the required specification. // Most of the directories which store the schemas are prefixed with a v so as to // match the tagging in the Kubernetes repository, apart from master. @@ -51,23 +56,23 @@ func determineSchema(kind, apiVersion string, config *Config) string { strictSuffix = "-strict" } + if config.OpenShift { + // If we're using the openshift schemas, there's no further processing required + return fmt.Sprintf("%s/%s-standalone%s/%s.json", baseURL, normalisedVersion, strictSuffix, strings.ToLower(kind)) + } + groupParts := strings.Split(apiVersion, "/") versionParts := strings.Split(groupParts[0], ".") - kindSuffix := "" - if !config.OpenShift { - if len(groupParts) == 1 { - kindSuffix = "-" + strings.ToLower(versionParts[0]) - } else { - kindSuffix = fmt.Sprintf("-%s-%s", strings.ToLower(versionParts[0]), strings.ToLower(groupParts[1])) - } + kindSuffix := "-" + strings.ToLower(versionParts[0]) + if len(groupParts) > 1 { + kindSuffix += "-" + strings.ToLower(groupParts[1]) } - baseURL := determineBaseURL(config) return fmt.Sprintf("%s/%s-standalone%s/%s%s.json", baseURL, normalisedVersion, strictSuffix, strings.ToLower(kind), kindSuffix) } -func determineBaseURL(config *Config) string { +func determineSchemaBaseURL(config *Config) string { // Order of precendence: // 1. If --openshift is passed, return the openshift schema location // 2. If a --schema-location is passed, use it @@ -133,21 +138,10 @@ func validateAgainstSchema(body interface{}, resource *ValidationResult, schemaC if config.IgnoreMissingSchemas { log.Warn("Warning: Set to ignore missing schemas") } - schemaRef := determineSchema(resource.Kind, resource.APIVersion, config) - schema, ok := schemaCache[schemaRef] - if !ok { - schemaLoader := gojsonschema.NewReferenceLoader(schemaRef) - var err error - schema, err = gojsonschema.NewSchema(schemaLoader) - schemaCache[schemaRef] = schema - - if err != nil { - return handleMissingSchema(fmt.Errorf("Failed initalizing schema %s: %s", schemaRef, err), config) - } - } - if schema == nil { - return handleMissingSchema(fmt.Errorf("Failed initalizing schema %s: see first error", schemaRef), config) + schema, err := downloadSchema(resource, schemaCache, config) + if err != nil { + return handleMissingSchema(err, config) } // Without forcing these types the schema fails to load @@ -160,15 +154,53 @@ func validateAgainstSchema(body interface{}, resource *ValidationResult, schemaC documentLoader := gojsonschema.NewGoLoader(body) results, err := schema.Validate(documentLoader) if err != nil { - return []gojsonschema.ResultError{}, fmt.Errorf("Problem loading schema from the network at %s: %s", schemaRef, err) + // This error can only happen if the Object to validate is poorly formed. There's no hope of saving this one + wrappedErr := fmt.Errorf("Problem validating schema. Check JSON formatting: %s", err) + return []gojsonschema.ResultError{}, wrappedErr } resource.ValidatedAgainstSchema = true if !results.Valid() { return results.Errors(), nil } + return []gojsonschema.ResultError{}, nil } +func downloadSchema(resource *ValidationResult, schemaCache map[string]*gojsonschema.Schema, config *Config) (*gojsonschema.Schema, error) { + if schema, ok := schemaCache[resource.VersionKind()]; ok { + // If the schema was previously cached, there's no work to be done + return schema, nil + } + + // We haven't cached this schema yet; look for one that works + primarySchemaBaseURL := determineSchemaBaseURL(config) + primarySchemaRef := determineSchemaURL(primarySchemaBaseURL, resource.Kind, resource.APIVersion, config) + schemaRefs := []string{primarySchemaRef} + + for _, additionalSchemaURLs := range config.AdditionalSchemaLocations { + additionalSchemaRef := determineSchemaURL(additionalSchemaURLs, resource.Kind, resource.APIVersion, config) + schemaRefs = append(schemaRefs, additionalSchemaRef) + } + + var errors *multierror.Error + for _, schemaRef := range schemaRefs { + schemaLoader := gojsonschema.NewReferenceLoader(schemaRef) + schema, err := gojsonschema.NewSchema(schemaLoader) + if err == nil { + // success! cache this and stop looking + schemaCache[resource.VersionKind()] = schema + return schema, nil + } + // We couldn't find a schema for this URL, so take a note, then try the next URL + wrappedErr := fmt.Errorf("Failed initalizing schema %s: %s", schemaRef, err) + errors = multierror.Append(errors, wrappedErr) + } + + // We couldn't find a schema for this resource. Cache it's lack of existence, then stop + schemaCache[resource.VersionKind()] = nil + return nil, errors.ErrorOrNil() +} + func handleMissingSchema(err error, config *Config) ([]gojsonschema.ResultError, error) { if config.IgnoreMissingSchemas { return []gojsonschema.ResultError{}, nil diff --git a/kubeval/kubeval_test.go b/kubeval/kubeval_test.go index 9d31cf0..aca7b90 100644 --- a/kubeval/kubeval_test.go +++ b/kubeval/kubeval_test.go @@ -192,43 +192,59 @@ func TestValidateMultipleResourcesWithErrors(t *testing.T) { } } -func TestDetermineSchema(t *testing.T) { - config := NewDefaultConfig() - schema := determineSchema("sample", "v1", config) - if schema != "https://kubernetesjsonschema.dev/master-standalone/sample-v1.json" { - t.Errorf("Schema should default to master, instead %s", schema) - } -} - -func TestDetermineSchemaForVersions(t *testing.T) { - config := NewDefaultConfig() - config.KubernetesVersion = "1.0" - schema := determineSchema("sample", "v1", config) - if schema != "https://kubernetesjsonschema.dev/v1.0-standalone/sample-v1.json" { - t.Errorf("Should be able to specify a version, instead %s", schema) +func TestDetermineSchemaURL(t *testing.T) { + var tests = []struct { + config *Config + baseURL string + kind string + version string + expected string + }{ + { + config: NewDefaultConfig(), + baseURL: "https://base", + kind: "sample", + version: "v1", + expected: "https://base/master-standalone/sample-v1.json", + }, + { + config: &Config{KubernetesVersion: "2"}, + baseURL: "https://base", + kind: "sample", + version: "v1", + expected: "https://base/v2-standalone/sample-v1.json", + }, + { + config: &Config{KubernetesVersion: "master", Strict: true}, + baseURL: "https://base", + kind: "sample", + version: "v1", + expected: "https://base/master-standalone-strict/sample-v1.json", + }, + { + config: NewDefaultConfig(), + baseURL: "https://base", + kind: "sample", + version: "extensions/v1beta1", + expected: "https://base/master-standalone/sample-extensions-v1beta1.json", + }, + { + config: &Config{KubernetesVersion: "master", OpenShift: true}, + baseURL: "https://base", + kind: "sample", + version: "v1", + expected: "https://base/master-standalone/sample.json", + }, } -} - -func TestDetermineSchemaForOpenShift(t *testing.T) { - config := NewDefaultConfig() - config.OpenShift = true - schema := determineSchema("sample", "v1", config) - if schema != "https://raw.githubusercontent.com/garethr/openshift-json-schema/master/master-standalone/sample.json" { - t.Errorf("Should be able to toggle to OpenShift schemas, instead %s", schema) + for _, test := range tests { + schemaURL := determineSchemaURL(test.baseURL, test.kind, test.version, test.config) + if schemaURL != test.expected { + t.Errorf("Schema URL should be %s, got %s", test.expected, schemaURL) + } } } func TestDetermineSchemaForSchemaLocation(t *testing.T) { - config := NewDefaultConfig() - config.SchemaLocation = "file:///home/me" - schema := determineSchema("sample", "v1", config) - expectedSchema := "file:///home/me/master-standalone/sample-v1.json" - if schema != expectedSchema { - t.Errorf("Should be able to specify a schema location, expected %s, got %s instead ", expectedSchema, schema) - } -} - -func TestDetermineSchemaForEnvVariable(t *testing.T) { oldVal, found := os.LookupEnv("KUBEVAL_SCHEMA_LOCATION") defer func() { if found { @@ -237,43 +253,70 @@ func TestDetermineSchemaForEnvVariable(t *testing.T) { os.Unsetenv("KUBEVAL_SCHEMA_LOCATION") } }() - config := NewDefaultConfig() - os.Setenv("KUBEVAL_SCHEMA_LOCATION", "file:///home/me") - schema := determineSchema("sample", "v1", config) - expectedSchema := "file:///home/me/master-standalone/sample-v1.json" - if schema != expectedSchema { - t.Errorf("Should be able to specify a schema location, expected %s, got %s instead ", expectedSchema, schema) + + var tests = []struct { + config *Config + envVar string + expected string + }{ + { + config: &Config{OpenShift: true}, + envVar: "", + expected: OpenShiftSchemaLocation, + }, + { + config: &Config{SchemaLocation: "https://base"}, + envVar: "", + expected: "https://base", + }, + { + config: &Config{}, + envVar: "https://base", + expected: "https://base", + }, + { + config: &Config{}, + envVar: "", + expected: DefaultSchemaLocation, + }, + } + for i, test := range tests { + os.Setenv("KUBEVAL_SCHEMA_LOCATION", test.envVar) + schemaBaseURL := determineSchemaBaseURL(test.config) + if schemaBaseURL != test.expected { + t.Errorf("test #%d: Schema Base URL should be %s, got %s", i, test.expected, schemaBaseURL) + } } } func TestGetString(t *testing.T) { - var tests = []struct{ - body map[string]interface{} - key string + var tests = []struct { + body map[string]interface{} + key string expectedVal string expectError bool }{ { - body: map[string]interface{}{"goodKey": "goodVal"}, - key: "goodKey", + body: map[string]interface{}{"goodKey": "goodVal"}, + key: "goodKey", expectedVal: "goodVal", expectError: false, }, { - body: map[string]interface{}{}, - key: "missingKey", + body: map[string]interface{}{}, + key: "missingKey", expectedVal: "", expectError: true, }, { - body: map[string]interface{}{"nilKey": nil}, - key: "nilKey", + body: map[string]interface{}{"nilKey": nil}, + key: "nilKey", expectedVal: "", expectError: true, }, { - body: map[string]interface{}{"badKey": 5}, - key: "badKey", + body: map[string]interface{}{"badKey": 5}, + key: "badKey", expectedVal: "", expectError: true, }, @@ -322,6 +365,28 @@ func TestSkipCrdSchemaMiss(t *testing.T) { } } +func TestAdditionalSchemas(t *testing.T) { + // This test uses a hack - first tell kubeval to use a bogus URL as its + // primary search location, then give the DefaultSchemaLocation as an + // additional schema. + // This should cause kubeval to fail when looking for the schema in the + // primary location, then succeed when it finds the schema at the + // "additional location" + config := NewDefaultConfig() + config.SchemaLocation = "testLocation" + config.AdditionalSchemaLocations = []string{DefaultSchemaLocation} + + config.FileName = "valid.yaml" + filePath, _ := filepath.Abs("../fixtures/valid.yaml") + fileContents, _ := ioutil.ReadFile(filePath) + results, err := Validate(fileContents, config) + if err != nil { + t.Errorf("Unexpected error: %s", err.Error()) + } else if len(results[0].Errors) != 0 { + t.Errorf("Validate should pass when testing a valid configuration using additional schema") + } +} + func TestFlagAdding(t *testing.T) { cmd := &cobra.Command{} config := &Config{} @@ -336,6 +401,7 @@ func TestFlagAdding(t *testing.T) { "filename", "skip-kinds", "schema-location", + "additional-schema-locations", "kubernetes-version", }