Skip to content

Commit f424282

Browse files
committed
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.
1 parent 4e5d0f6 commit f424282

File tree

3 files changed

+180
-76
lines changed

3 files changed

+180
-76
lines changed

kubeval/config.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,11 @@ type Config struct {
2222
// It can be either a remote location or a local directory
2323
SchemaLocation string
2424

25+
// AdditionalSchemaLocations is a list of alternative base URLs from
26+
// which to search for schemas, given that the desired schema was not
27+
// found at SchemaLocation
28+
AdditionalSchemaLocations []string
29+
2530
// OpenShift represents whether to test against
2631
// upstream Kubernetes or the OpenShift schemas
2732
OpenShift bool
@@ -67,6 +72,7 @@ func AddKubevalFlags(cmd *cobra.Command, config *Config) *cobra.Command {
6772
cmd.Flags().StringVarP(&config.FileName, "filename", "f", "stdin", "filename to be displayed when testing manifests read from stdin")
6873
cmd.Flags().StringSliceVar(&config.KindsToSkip, "skip-kinds", []string{}, "Comma-separated list of case-sensitive kinds to skip when validating against schemas")
6974
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.")
75+
cmd.Flags().StringSliceVar(&config.AdditionalSchemaLocations , "additional-schema-locations", []string{}, "Comma-seperated list of secondary base URLs used to download schemas")
7076
cmd.Flags().StringVarP(&config.KubernetesVersion, "kubernetes-version", "v", "master", "Version of Kubernetes to validate against")
7177
cmd.Flags().StringVarP(&config.OutputFormat, "output", "o", "", fmt.Sprintf("The format of the output of this script. Options are: %v", validOutputs()))
7278

kubeval/kubeval.go

Lines changed: 59 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -34,10 +34,15 @@ type ValidationResult struct {
3434
Errors []gojsonschema.ResultError
3535
}
3636

37-
func determineSchema(kind, apiVersion string, config *Config) string {
37+
// VersionKind returns a string representation of this result's apiVersion and kind
38+
func (v *ValidationResult) VersionKind() string {
39+
return v.APIVersion + "/" + v.Kind
40+
}
41+
42+
func determineSchemaURL(baseURL, kind, apiVersion string, config *Config) string {
3843
// We have both the upstream Kubernetes schemas and the OpenShift schemas available
39-
// the tool can toggle between then using the config.Openshift boolean flag and here we
40-
// use that to select which repository to get the schema from
44+
// the tool can toggle between then using the config.OpenShift boolean flag and here we
45+
// use that to format the URL to match the required specification.
4146

4247
// Most of the directories which store the schemas are prefixed with a v so as to
4348
// match the tagging in the Kubernetes repository, apart from master.
@@ -51,23 +56,23 @@ func determineSchema(kind, apiVersion string, config *Config) string {
5156
strictSuffix = "-strict"
5257
}
5358

59+
if config.OpenShift {
60+
// If we're using the openshift schemas, there's no further processing required
61+
return fmt.Sprintf("%s/%s-standalone%s/%s.json", baseURL, normalisedVersion, strictSuffix, strings.ToLower(kind))
62+
}
63+
5464
groupParts := strings.Split(apiVersion, "/")
5565
versionParts := strings.Split(groupParts[0], ".")
5666

57-
kindSuffix := ""
58-
if !config.OpenShift {
59-
if len(groupParts) == 1 {
60-
kindSuffix = "-" + strings.ToLower(versionParts[0])
61-
} else {
62-
kindSuffix = fmt.Sprintf("-%s-%s", strings.ToLower(versionParts[0]), strings.ToLower(groupParts[1]))
63-
}
67+
kindSuffix := "-" + strings.ToLower(versionParts[0])
68+
if len(groupParts) > 1 {
69+
kindSuffix += "-" + strings.ToLower(groupParts[1])
6470
}
6571

66-
baseURL := determineBaseURL(config)
6772
return fmt.Sprintf("%s/%s-standalone%s/%s%s.json", baseURL, normalisedVersion, strictSuffix, strings.ToLower(kind), kindSuffix)
6873
}
6974

70-
func determineBaseURL(config *Config) string {
75+
func determineSchemaBaseURL(config *Config) string {
7176
// Order of precendence:
7277
// 1. If --openshift is passed, return the openshift schema location
7378
// 2. If a --schema-location is passed, use it
@@ -133,21 +138,10 @@ func validateAgainstSchema(body interface{}, resource *ValidationResult, schemaC
133138
if config.IgnoreMissingSchemas {
134139
log.Warn("Warning: Set to ignore missing schemas")
135140
}
136-
schemaRef := determineSchema(resource.Kind, resource.APIVersion, config)
137-
schema, ok := schemaCache[schemaRef]
138-
if !ok {
139-
schemaLoader := gojsonschema.NewReferenceLoader(schemaRef)
140-
var err error
141-
schema, err = gojsonschema.NewSchema(schemaLoader)
142-
schemaCache[schemaRef] = schema
143-
144-
if err != nil {
145-
return handleMissingSchema(fmt.Errorf("Failed initalizing schema %s: %s", schemaRef, err), config)
146-
}
147-
}
148141

149-
if schema == nil {
150-
return handleMissingSchema(fmt.Errorf("Failed initalizing schema %s: see first error", schemaRef), config)
142+
schema, err := downloadSchema(resource, schemaCache, config)
143+
if err != nil {
144+
return handleMissingSchema(err, config)
151145
}
152146

153147
// Without forcing these types the schema fails to load
@@ -160,15 +154,53 @@ func validateAgainstSchema(body interface{}, resource *ValidationResult, schemaC
160154
documentLoader := gojsonschema.NewGoLoader(body)
161155
results, err := schema.Validate(documentLoader)
162156
if err != nil {
163-
return []gojsonschema.ResultError{}, fmt.Errorf("Problem loading schema from the network at %s: %s", schemaRef, err)
157+
// This error can only happen if the Object to validate is poorly formed. There's no hope of saving this one
158+
wrappedErr := fmt.Errorf("Problem validating schema. Check JSON formatting: %s", err)
159+
return []gojsonschema.ResultError{}, wrappedErr
164160
}
165161
resource.ValidatedAgainstSchema = true
166162
if !results.Valid() {
167163
return results.Errors(), nil
168164
}
165+
169166
return []gojsonschema.ResultError{}, nil
170167
}
171168

169+
func downloadSchema(resource *ValidationResult, schemaCache map[string]*gojsonschema.Schema, config *Config) (*gojsonschema.Schema, error) {
170+
if schema, ok := schemaCache[resource.VersionKind()]; ok {
171+
// If the schema was previously cached, there's no work to be done
172+
return schema, nil
173+
}
174+
175+
// We haven't cached this schema yet; look for one that works
176+
primarySchemaBaseURL := determineSchemaBaseURL(config)
177+
primarySchemaRef := determineSchemaURL(primarySchemaBaseURL, resource.Kind, resource.APIVersion, config)
178+
schemaRefs := []string{primarySchemaRef}
179+
180+
for _, additionalSchemaURLs := range config.AdditionalSchemaLocations {
181+
additionalSchemaRef := determineSchemaURL(additionalSchemaURLs, resource.Kind, resource.APIVersion, config)
182+
schemaRefs = append(schemaRefs, additionalSchemaRef)
183+
}
184+
185+
var errors *multierror.Error
186+
for _, schemaRef := range schemaRefs {
187+
schemaLoader := gojsonschema.NewReferenceLoader(schemaRef)
188+
schema, err := gojsonschema.NewSchema(schemaLoader)
189+
if err == nil {
190+
// success! cache this and stop looking
191+
schemaCache[resource.VersionKind()] = schema
192+
return schema, nil
193+
}
194+
// We couldn't find a schema for this URL, so take a note, then try the next URL
195+
wrappedErr := fmt.Errorf("Failed initalizing schema %s: %s", schemaRef, err)
196+
errors = multierror.Append(errors, wrappedErr)
197+
}
198+
199+
// We couldn't find a schema for this resource. Cache it's lack of existence, then stop
200+
schemaCache[resource.VersionKind()] = nil
201+
return nil, errors.ErrorOrNil()
202+
}
203+
172204
func handleMissingSchema(err error, config *Config) ([]gojsonschema.ResultError, error) {
173205
if config.IgnoreMissingSchemas {
174206
return []gojsonschema.ResultError{}, nil

kubeval/kubeval_test.go

Lines changed: 115 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -192,43 +192,59 @@ func TestValidateMultipleResourcesWithErrors(t *testing.T) {
192192
}
193193
}
194194

195-
func TestDetermineSchema(t *testing.T) {
196-
config := NewDefaultConfig()
197-
schema := determineSchema("sample", "v1", config)
198-
if schema != "https://kubernetesjsonschema.dev/master-standalone/sample-v1.json" {
199-
t.Errorf("Schema should default to master, instead %s", schema)
200-
}
201-
}
202-
203-
func TestDetermineSchemaForVersions(t *testing.T) {
204-
config := NewDefaultConfig()
205-
config.KubernetesVersion = "1.0"
206-
schema := determineSchema("sample", "v1", config)
207-
if schema != "https://kubernetesjsonschema.dev/v1.0-standalone/sample-v1.json" {
208-
t.Errorf("Should be able to specify a version, instead %s", schema)
195+
func TestDetermineSchemaURL(t *testing.T) {
196+
var tests = []struct {
197+
config *Config
198+
baseURL string
199+
kind string
200+
version string
201+
expected string
202+
}{
203+
{
204+
config: NewDefaultConfig(),
205+
baseURL: "https://base",
206+
kind: "sample",
207+
version: "v1",
208+
expected: "https://base/master-standalone/sample-v1.json",
209+
},
210+
{
211+
config: &Config{KubernetesVersion: "2"},
212+
baseURL: "https://base",
213+
kind: "sample",
214+
version: "v1",
215+
expected: "https://base/v2-standalone/sample-v1.json",
216+
},
217+
{
218+
config: &Config{KubernetesVersion: "master", Strict: true},
219+
baseURL: "https://base",
220+
kind: "sample",
221+
version: "v1",
222+
expected: "https://base/master-standalone-strict/sample-v1.json",
223+
},
224+
{
225+
config: NewDefaultConfig(),
226+
baseURL: "https://base",
227+
kind: "sample",
228+
version: "extensions/v1beta1",
229+
expected: "https://base/master-standalone/sample-extensions-v1beta1.json",
230+
},
231+
{
232+
config: &Config{KubernetesVersion: "master", OpenShift: true},
233+
baseURL: "https://base",
234+
kind: "sample",
235+
version: "v1",
236+
expected: "https://base/master-standalone/sample.json",
237+
},
209238
}
210-
}
211-
212-
func TestDetermineSchemaForOpenShift(t *testing.T) {
213-
config := NewDefaultConfig()
214-
config.OpenShift = true
215-
schema := determineSchema("sample", "v1", config)
216-
if schema != "https://raw.githubusercontent.com/garethr/openshift-json-schema/master/master-standalone/sample.json" {
217-
t.Errorf("Should be able to toggle to OpenShift schemas, instead %s", schema)
239+
for _, test := range tests {
240+
schemaURL := determineSchemaURL(test.baseURL, test.kind, test.version, test.config)
241+
if schemaURL != test.expected {
242+
t.Errorf("Schema URL should be %s, got %s", test.expected, schemaURL)
243+
}
218244
}
219245
}
220246

221247
func TestDetermineSchemaForSchemaLocation(t *testing.T) {
222-
config := NewDefaultConfig()
223-
config.SchemaLocation = "file:///home/me"
224-
schema := determineSchema("sample", "v1", config)
225-
expectedSchema := "file:///home/me/master-standalone/sample-v1.json"
226-
if schema != expectedSchema {
227-
t.Errorf("Should be able to specify a schema location, expected %s, got %s instead ", expectedSchema, schema)
228-
}
229-
}
230-
231-
func TestDetermineSchemaForEnvVariable(t *testing.T) {
232248
oldVal, found := os.LookupEnv("KUBEVAL_SCHEMA_LOCATION")
233249
defer func() {
234250
if found {
@@ -237,43 +253,70 @@ func TestDetermineSchemaForEnvVariable(t *testing.T) {
237253
os.Unsetenv("KUBEVAL_SCHEMA_LOCATION")
238254
}
239255
}()
240-
config := NewDefaultConfig()
241-
os.Setenv("KUBEVAL_SCHEMA_LOCATION", "file:///home/me")
242-
schema := determineSchema("sample", "v1", config)
243-
expectedSchema := "file:///home/me/master-standalone/sample-v1.json"
244-
if schema != expectedSchema {
245-
t.Errorf("Should be able to specify a schema location, expected %s, got %s instead ", expectedSchema, schema)
256+
257+
var tests = []struct {
258+
config *Config
259+
envVar string
260+
expected string
261+
}{
262+
{
263+
config: &Config{OpenShift: true},
264+
envVar: "",
265+
expected: OpenShiftSchemaLocation,
266+
},
267+
{
268+
config: &Config{SchemaLocation: "https://base"},
269+
envVar: "",
270+
expected: "https://base",
271+
},
272+
{
273+
config: &Config{},
274+
envVar: "https://base",
275+
expected: "https://base",
276+
},
277+
{
278+
config: &Config{},
279+
envVar: "",
280+
expected: DefaultSchemaLocation,
281+
},
282+
}
283+
for i, test := range tests {
284+
os.Setenv("KUBEVAL_SCHEMA_LOCATION", test.envVar)
285+
schemaBaseURL := determineSchemaBaseURL(test.config)
286+
if schemaBaseURL != test.expected {
287+
t.Errorf("test #%d: Schema Base URL should be %s, got %s", i, test.expected, schemaBaseURL)
288+
}
246289
}
247290
}
248291

249292
func TestGetString(t *testing.T) {
250-
var tests = []struct{
251-
body map[string]interface{}
252-
key string
293+
var tests = []struct {
294+
body map[string]interface{}
295+
key string
253296
expectedVal string
254297
expectError bool
255298
}{
256299
{
257-
body: map[string]interface{}{"goodKey": "goodVal"},
258-
key: "goodKey",
300+
body: map[string]interface{}{"goodKey": "goodVal"},
301+
key: "goodKey",
259302
expectedVal: "goodVal",
260303
expectError: false,
261304
},
262305
{
263-
body: map[string]interface{}{},
264-
key: "missingKey",
306+
body: map[string]interface{}{},
307+
key: "missingKey",
265308
expectedVal: "",
266309
expectError: true,
267310
},
268311
{
269-
body: map[string]interface{}{"nilKey": nil},
270-
key: "nilKey",
312+
body: map[string]interface{}{"nilKey": nil},
313+
key: "nilKey",
271314
expectedVal: "",
272315
expectError: true,
273316
},
274317
{
275-
body: map[string]interface{}{"badKey": 5},
276-
key: "badKey",
318+
body: map[string]interface{}{"badKey": 5},
319+
key: "badKey",
277320
expectedVal: "",
278321
expectError: true,
279322
},
@@ -322,6 +365,28 @@ func TestSkipCrdSchemaMiss(t *testing.T) {
322365
}
323366
}
324367

368+
func TestAdditionalSchemas(t *testing.T) {
369+
// This test uses a hack - first tell kubeval to use a bogus URL as its
370+
// primary search location, then give the DefaultSchemaLocation as an
371+
// additional schema.
372+
// This should cause kubeval to fail when looking for the schema in the
373+
// primary location, then succeed when it finds the schema at the
374+
// "additional location"
375+
config := NewDefaultConfig()
376+
config.SchemaLocation = "testLocation"
377+
config.AdditionalSchemaLocations = []string{DefaultSchemaLocation}
378+
379+
config.FileName = "valid.yaml"
380+
filePath, _ := filepath.Abs("../fixtures/valid.yaml")
381+
fileContents, _ := ioutil.ReadFile(filePath)
382+
results, err := Validate(fileContents, config)
383+
if err != nil {
384+
t.Errorf("Unexpected error: %s", err.Error())
385+
} else if len(results[0].Errors) != 0 {
386+
t.Errorf("Validate should pass when testing a valid configuration using additional schema")
387+
}
388+
}
389+
325390
func TestFlagAdding(t *testing.T) {
326391
cmd := &cobra.Command{}
327392
config := &Config{}
@@ -336,6 +401,7 @@ func TestFlagAdding(t *testing.T) {
336401
"filename",
337402
"skip-kinds",
338403
"schema-location",
404+
"additional-schema-locations",
339405
"kubernetes-version",
340406
}
341407

0 commit comments

Comments
 (0)