Skip to content

Commit

Permalink
Merge pull request #169 from ian-howell/feat/aggregate-schemas
Browse files Browse the repository at this point in the history
Add the --additional-schema-locations flag
  • Loading branch information
garethr authored Aug 13, 2019
2 parents 34b303a + f424282 commit a766f3c
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 a766f3c

Please sign in to comment.