Support for CRDs

This commit is contained in:
Yann Hamon 2020-10-16 23:53:41 +02:00
parent f7bfd2c960
commit b4995aa02c
11 changed files with 237 additions and 39 deletions

1
.gitignore vendored
View file

@ -1 +1,2 @@
dist/ dist/
bin/

View file

@ -1,6 +1,6 @@
#!/usr/bin/make -f #!/usr/bin/make -f
.PHONY: test-build test build build-static docker-test docker-build-static build-bats docker-acceptance docker-image .PHONY: test-build test build build-static docker-test docker-build-static build-bats docker-acceptance docker-image release
test-build: test build test-build: test build
@ -8,13 +8,13 @@ test:
go test ./... go test ./...
build: build:
go build -o bin/kubeconform go build -o bin/ ./...
docker-image: docker-image:
docker build -t kubeconform . docker build -t kubeconform .
build-static: build-static:
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 GO111MODULE=on go build -a -o bin/kubeconform CGO_ENABLED=0 GOOS=linux GOARCH=amd64 GO111MODULE=on go build -a -o bin/ ./...
docker-test: docker-test:
docker run -t -v $$PWD:/go/src/github.com/yannh/kubeconform -w /go/src/github.com/yannh/kubeconform golang:1.14 make test docker run -t -v $$PWD:/go/src/github.com/yannh/kubeconform -w /go/src/github.com/yannh/kubeconform golang:1.14 make test

View file

@ -37,12 +37,12 @@ Usage of ./bin/kubeconform:
skip files with missing schemas instead of failing skip files with missing schemas instead of failing
-k8sversion string -k8sversion string
version of Kubernetes to test against (default "1.18.0") version of Kubernetes to test against (default "1.18.0")
-local-registry value
folder containing additional schemas (can be specified multiple times)
-n int -n int
number of routines to run in parallel (default 4) number of routines to run in parallel (default 4)
-output string -output string
output format - text, json (default "text") output format - text, json (default "text")
-registry value
override schemas registry path (can be specified multiple times)
-skip string -skip string
comma-separated list of kinds to ignore comma-separated list of kinds to ignore
-strict -strict
@ -95,6 +95,28 @@ fixtures/invalid.yaml - ReplicationController is invalid: Invalid type. Expected
Summary: 48 resources found in 25 files - Valid: 39, Invalid: 2, Errors: 7 Skipped: 0 Summary: 48 resources found in 25 files - Valid: 39, Invalid: 2, Errors: 7 Skipped: 0
``` ```
### Overriding schemas registries lookup order - CRD support
When the `-registry` file is not used, kubeconform will default to downloading schemas from
`kubernetesjsonschema.dev`. Kubeconform however supports the use of one, or multiple, custom schemas
registries - with access over HTTP or local filesystem. Kubeconform will lookup for schema definitions
in each of them, in order, stopping as soon as a matching file is found.
All 3 following command lines are equivalent:
```
$ ./bin/kubeconform fixtures/valid.yaml
$ ./bin/kubeconform -registry kubernetesjsonschema.dev fixtures/valid.yaml
$ ./bin/kubeconform -registry 'https://kubernetesjsonschema.dev/{{ .NormalizedVersion }}-standalone{{ .StrictSuffix }}/{{ .ResourceKind }}{{ .KindSuffix }}.json' fixtures/valid.yaml
```
To support validating CRDs, we need to convert OpenAPI files to JSON schema, storing the JSON schemas
in a local folder - for example schemas. Then we specify this folder as an additional registry to lookup:
```
# If the resource Kind is not found in kubernetesjsonschema.dev, also lookup in the schemas/ folder for a matching file
$ ./bin/kubeconform -registry kubernetesjsonschema.dev -registry 'schemas/{{ .ResourceKind }}{{ .KindSuffix }}.json' fixtures/custom-resource.yaml
```
### Credits ### Credits
* @garethr for the [Kubeval](https://github.com/instrumenta/kubeval) and * @garethr for the [Kubeval](https://github.com/instrumenta/kubeval) and

View file

@ -9,7 +9,6 @@ import (
"github.com/yannh/kubeconform/pkg/output" "github.com/yannh/kubeconform/pkg/output"
"io" "io"
"io/ioutil" "io/ioutil"
"log"
"os" "os"
"strings" "strings"
"sync" "sync"
@ -210,7 +209,7 @@ func getFiles(files []string, fileBatches chan []string, validationResults chan
} }
func realMain() int { func realMain() int {
var localRegistryFolders arrayParam var regs arrayParam
var skipKindsCSV, k8sVersion, outputFormat string var skipKindsCSV, k8sVersion, outputFormat string
var summary, strict, verbose, ignoreMissingSchemas bool var summary, strict, verbose, ignoreMissingSchemas bool
var nWorkers int var nWorkers int
@ -218,7 +217,7 @@ func realMain() int {
var files []string var files []string
flag.StringVar(&k8sVersion, "k8sversion", "1.18.0", "version of Kubernetes to test against") flag.StringVar(&k8sVersion, "k8sversion", "1.18.0", "version of Kubernetes to test against")
flag.Var(&localRegistryFolders, "local-registry", "folder containing additional schemas (can be specified multiple times)") flag.Var(&regs, "registry", "override schemas registry path (can be specified multiple times)")
flag.BoolVar(&ignoreMissingSchemas, "ignore-missing-schemas", false, "skip files with missing schemas instead of failing") flag.BoolVar(&ignoreMissingSchemas, "ignore-missing-schemas", false, "skip files with missing schemas instead of failing")
flag.BoolVar(&summary, "summary", false, "print a summary at the end") flag.BoolVar(&summary, "summary", false, "print a summary at the end")
flag.IntVar(&nWorkers, "n", 4, "number of routines to run in parallel") flag.IntVar(&nWorkers, "n", 4, "number of routines to run in parallel")
@ -240,14 +239,17 @@ func realMain() int {
} }
registries := []registry.Registry{} registries := []registry.Registry{}
registries = append(registries, registry.NewKubernetesRegistry(strict)) if len(regs) == 0 {
if len(localRegistryFolders) > 0 { regs = append(regs, "kubernetesjsonschema.dev") // if not specified, default behaviour is to use kubernetesjson-schema.dev as registry
for _, localRegistryFolder := range localRegistryFolders { }
localRegistry, err := registry.NewLocalRegistry(localRegistryFolder, strict)
if err != nil { for _, reg := range regs {
log.Fatalf("%s", err) if reg == "kubernetesjsonschema.dev" {
} registries = append(registries, registry.NewHTTPRegistry("https://kubernetesjsonschema.dev/{{ .NormalizedVersion }}-standalone{{ .StrictSuffix }}/{{ .ResourceKind }}{{ .KindSuffix }}.json", strict))
registries = append(registries, localRegistry) } else if strings.HasPrefix(reg, "http") {
registries = append(registries, registry.NewHTTPRegistry(reg, strict))
} else {
registries = append(registries, registry.NewLocalRegistry(reg, strict))
} }
} }

91
cmd/openapi2jsonschema/main.py Executable file
View file

@ -0,0 +1,91 @@
#!/usr/bin/env python
import yaml
import json
def iteritems(d):
if hasattr(dict, "iteritems"):
return d.iteritems()
else:
return iter(d.items())
def additional_properties(data):
"This recreates the behaviour of kubectl at https://github.com/kubernetes/kubernetes/blob/225b9119d6a8f03fcbe3cc3d590c261965d928d0/pkg/kubectl/validation/schema.go#L312"
new = {}
try:
for k, v in iteritems(data):
new_v = v
if isinstance(v, dict):
if "properties" in v:
if "additionalProperties" not in v:
v["additionalProperties"] = False
new_v = additional_properties(v)
else:
new_v = v
new[k] = new_v
return new
except AttributeError:
return data
def replace_int_or_string(data):
new = {}
try:
for k, v in iteritems(data):
new_v = v
if isinstance(v, dict):
if "format" in v and v["format"] == "int-or-string":
new_v = {"oneOf": [{"type": "string"}, {"type": "integer"}]}
else:
new_v = replace_int_or_string(v)
elif isinstance(v, list):
new_v = list()
for x in v:
new_v.append(replace_int_or_string(x))
else:
new_v = v
new[k] = new_v
return new
except AttributeError:
return data
def allow_null_optional_fields(data, parent=None, grand_parent=None, key=None):
new = {}
try:
for k, v in iteritems(data):
new_v = v
if isinstance(v, dict):
new_v = allow_null_optional_fields(v, data, parent, k)
elif isinstance(v, list):
new_v = list()
for x in v:
new_v.append(allow_null_optional_fields(x, v, parent, k))
elif isinstance(v, str):
is_non_null_type = k == "type" and v != "null"
has_required_fields = grand_parent and "required" in grand_parent
if is_non_null_type and not has_required_field:
new_v = [v, "null"]
new[k] = new_v
return new
except AttributeError:
return data
def append_no_duplicates(obj, key, value):
"""
Given a dictionary, lookup the given key, if it doesn't exist create a new array.
Then check if the given value already exists in the array, if it doesn't add it.
"""
if key not in obj:
obj[key] = []
if value not in obj[key]:
obj[key].append(value)
with open(r'synced_secrets.yaml') as f:
y = yaml.load(f, Loader=yaml.SafeLoader)
schema = y["spec"]["validation"]["openAPIV3Schema"]
schema = additional_properties(schema)
schema = replace_int_or_string(schema)
print(json.dumps(schema))

View file

@ -7,8 +7,8 @@ import (
) )
type KubernetesRegistry struct { type KubernetesRegistry struct {
baseURL string schemaPathTemplate string
strict bool strict bool
} }
type downloadError struct { type downloadError struct {
@ -22,15 +22,18 @@ func newDownloadError(err error, isRetryable bool) *downloadError {
func (e *downloadError) IsRetryable() bool { return e.isRetryable } func (e *downloadError) IsRetryable() bool { return e.isRetryable }
func (e *downloadError) Error() string { return e.err.Error() } func (e *downloadError) Error() string { return e.err.Error() }
func NewKubernetesRegistry(strict bool) *KubernetesRegistry { func NewHTTPRegistry(schemaPathTemplate string, strict bool) *KubernetesRegistry {
return &KubernetesRegistry{ return &KubernetesRegistry{
baseURL: "https://kubernetesjsonschema.dev", schemaPathTemplate: schemaPathTemplate,
strict: strict, strict: strict,
} }
} }
func (r KubernetesRegistry) DownloadSchema(resourceKind, resourceAPIVersion, k8sVersion string) ([]byte, error) { func (r KubernetesRegistry) DownloadSchema(resourceKind, resourceAPIVersion, k8sVersion string) ([]byte, error) {
url := r.baseURL + "/" + schemaPath(resourceKind, resourceAPIVersion, k8sVersion, r.strict) url, err := schemaPath(r.schemaPathTemplate, resourceKind, resourceAPIVersion, k8sVersion, r.strict)
if err != nil {
return nil, err
}
resp, err := http.Get(url) resp, err := http.Get(url)
if err != nil { if err != nil {

View file

@ -4,30 +4,47 @@ import (
"fmt" "fmt"
"io/ioutil" "io/ioutil"
"os" "os"
"path"
) )
type LocalRegistry struct { type LocalRegistry struct {
folder string pathTemplate string
strict bool strict bool
} }
type fileNotFoundError struct {
err error
isRetryable bool
}
func newFileNotFoundError(err error, isRetryable bool) *fileNotFoundError {
return &fileNotFoundError{err, isRetryable}
}
func (e *fileNotFoundError) IsRetryable() bool { return e.isRetryable }
func (e *fileNotFoundError) Error() string { return e.err.Error() }
// NewLocalSchemas creates a new "registry", that will serve schemas from files, given a list of schema filenames // NewLocalSchemas creates a new "registry", that will serve schemas from files, given a list of schema filenames
func NewLocalRegistry(folder string, strict bool) (*LocalRegistry, error) { func NewLocalRegistry(pathTemplate string, strict bool) *LocalRegistry {
return &LocalRegistry{ return &LocalRegistry{
folder, pathTemplate,
strict, strict,
}, nil }
} }
// DownloadSchema retrieves the schema from a file for the resource // DownloadSchema retrieves the schema from a file for the resource
func (r LocalRegistry) DownloadSchema(resourceKind, resourceAPIVersion, k8sVersion string) ([]byte, error) { func (r LocalRegistry) DownloadSchema(resourceKind, resourceAPIVersion, k8sVersion string) ([]byte, error) {
schemaFile := path.Join(r.folder, schemaPath(resourceKind, resourceAPIVersion, k8sVersion, r.strict)) schemaFile, err := schemaPath(r.pathTemplate, resourceKind, resourceAPIVersion, k8sVersion, r.strict)
if err != nil {
return []byte{}, nil
}
f, err := os.Open(schemaFile) f, err := os.Open(schemaFile)
if err != nil { if err != nil {
if os.IsNotExist(err) {
return nil, newFileNotFoundError(fmt.Errorf("no schema found"), false)
}
return nil, fmt.Errorf("failed to open schema %s", schemaFile) return nil, fmt.Errorf("failed to open schema %s", schemaFile)
} }
defer f.Close() defer f.Close()
content, err := ioutil.ReadAll(f) content, err := ioutil.ReadAll(f)
if err != nil { if err != nil {

View file

@ -1,8 +1,9 @@
package registry package registry
import ( import (
"fmt" "bytes"
"strings" "strings"
"text/template"
) )
type Manifest struct { type Manifest struct {
@ -19,7 +20,7 @@ type Retryable interface {
IsRetryable() bool IsRetryable() bool
} }
func schemaPath(resourceKind, resourceAPIVersion, k8sVersion string, strict bool) string { func schemaPath(tpl, resourceKind, resourceAPIVersion, k8sVersion string, strict bool) (string, error) {
normalisedVersion := k8sVersion normalisedVersion := k8sVersion
if normalisedVersion != "master" { if normalisedVersion != "master" {
normalisedVersion = "v" + normalisedVersion normalisedVersion = "v" + normalisedVersion
@ -38,5 +39,28 @@ func schemaPath(resourceKind, resourceAPIVersion, k8sVersion string, strict bool
kindSuffix += "-" + strings.ToLower(groupParts[1]) kindSuffix += "-" + strings.ToLower(groupParts[1])
} }
return fmt.Sprintf("%s-standalone%s/%s%s.json", normalisedVersion, strictSuffix, strings.ToLower(resourceKind), kindSuffix) tmpl, err := template.New("tpl").Parse(tpl)
if err != nil {
return "", err
}
tplData := struct {
NormalizedVersion string
StrictSuffix string
ResourceKind string
KindSuffix string
}{
normalisedVersion,
strictSuffix,
strings.ToLower(resourceKind),
kindSuffix,
}
var buf bytes.Buffer
err = tmpl.Execute(&buf, tplData)
if err != nil {
return "", err
}
return buf.String(), nil
} }

View file

@ -6,39 +6,52 @@ import (
func TestSchemaPath(t *testing.T) { func TestSchemaPath(t *testing.T) {
for i, testCase := range []struct { for i, testCase := range []struct {
resourceKind, resourceAPIVersion, k8sVersion, expected string tpl, resourceKind, resourceAPIVersion, k8sVersion, expected string
strict bool strict bool
errExpected error
}{ }{
{ {
"https://kubernetesjsonschema.dev/{{ .NormalizedVersion }}-standalone{{ .StrictSuffix }}/{{ .ResourceKind }}{{ .KindSuffix }}.json",
"Deployment", "Deployment",
"apps/v1", "apps/v1",
"1.16.0", "1.16.0",
"v1.16.0-standalone-strict/deployment-apps-v1.json", "https://kubernetesjsonschema.dev/v1.16.0-standalone-strict/deployment-apps-v1.json",
true, true,
nil,
}, },
{ {
"https://kubernetesjsonschema.dev/{{ .NormalizedVersion }}-standalone{{ .StrictSuffix }}/{{ .ResourceKind }}{{ .KindSuffix }}.json",
"Deployment", "Deployment",
"apps/v1", "apps/v1",
"1.16.0", "1.16.0",
"v1.16.0-standalone/deployment-apps-v1.json", "https://kubernetesjsonschema.dev/v1.16.0-standalone/deployment-apps-v1.json",
false, false,
nil,
}, },
{ {
"https://kubernetesjsonschema.dev/{{ .NormalizedVersion }}-standalone{{ .StrictSuffix }}/{{ .ResourceKind }}{{ .KindSuffix }}.json",
"Service", "Service",
"v1", "v1",
"1.18.0", "1.18.0",
"v1.18.0-standalone/service-v1.json", "https://kubernetesjsonschema.dev/v1.18.0-standalone/service-v1.json",
false, false,
nil,
}, },
{ {
"https://kubernetesjsonschema.dev/{{ .NormalizedVersion }}-standalone{{ .StrictSuffix }}/{{ .ResourceKind }}{{ .KindSuffix }}.json",
"Service", "Service",
"v1", "v1",
"master", "master",
"master-standalone/service-v1.json", "https://kubernetesjsonschema.dev/master-standalone/service-v1.json",
false, false,
nil,
}, },
} { } {
if got := schemaPath(testCase.resourceKind, testCase.resourceAPIVersion, testCase.k8sVersion, testCase.strict); got != testCase.expected { got, err := schemaPath(testCase.tpl, testCase.resourceKind, testCase.resourceAPIVersion, testCase.k8sVersion, testCase.strict)
if err != testCase.errExpected {
t.Errorf("%d - got error %s, expected %s", i+1, err, testCase.errExpected)
}
if got != testCase.expected {
t.Errorf("%d - got %s, expected %s", i+1, got, testCase.expected) t.Errorf("%d - got %s, expected %s", i+1, got, testCase.expected)
} }
} }

View file

@ -65,6 +65,31 @@ lastName: bar
}`), }`),
fmt.Errorf("Invalid type. Expected: number, given: string"), fmt.Errorf("Invalid type. Expected: number, given: string"),
}, },
{
"missing required field",
[]byte(`
firstName: foo
`),
[]byte(`{
"title": "Example Schema",
"type": "object",
"properties": {
"firstName": {
"type": "string"
},
"lastName": {
"type": "string"
},
"age": {
"description": "Age in years",
"type": "integer",
"minimum": 0
}
},
"required": ["firstName", "lastName"]
}`),
fmt.Errorf("lastName is required"),
},
{ {
"resource has invalid yaml", "resource has invalid yaml",
[]byte(` []byte(`