Skip to content

Commit

Permalink
Add the --additional-schema-locations flag
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
ian-howell committed Aug 12, 2019
1 parent 4e5d0f6 commit f424282
Show file tree
Hide file tree
Showing 3 changed files with 180 additions and 76 deletions.
6 changes: 6 additions & 0 deletions kubeval/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()))

Expand Down
86 changes: 59 additions & 27 deletions kubeval/kubeval.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
164 changes: 115 additions & 49 deletions kubeval/kubeval_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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,
},
Expand Down Expand Up @@ -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{}
Expand All @@ -336,6 +401,7 @@ func TestFlagAdding(t *testing.T) {
"filename",
"skip-kinds",
"schema-location",
"additional-schema-locations",
"kubernetes-version",
}

Expand Down

0 comments on commit f424282

Please sign in to comment.