Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Cache schemas downloaded over HTTP #24

Merged
merged 3 commits into from
Jan 2, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
6 changes: 5 additions & 1 deletion Readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ It is inspired by, contains code from and is designed to stay close to
* **high performance**: will validate & download manifests over multiple routines, caching
downloaded files in memory
* configurable list of **remote, or local schemas locations**, enabling validating Kubernetes
custom resources (CRDs)
custom resources (CRDs) and offline validation capabilities.

### A small overview of Kubernetes manifest validation

Expand Down Expand Up @@ -49,6 +49,10 @@ configuration errors.
```
$ ./bin/kubeconform -h
Usage: ./bin/kubeconform [OPTION]... [FILE OR FOLDER]...
-cache string
cache schemas downloaded via HTTP to this folder
-cpu-prof string
debug - log CPU profiling to file
-exit-on-error
immediately stop execution when the first error is encountered
-h show help information
Expand Down
14 changes: 14 additions & 0 deletions acceptance.bats
Original file line number Diff line number Diff line change
Expand Up @@ -202,3 +202,17 @@
[ "$status" -eq 0 ]
[ "$output" = "Summary: 1 resource found in 1 file - Valid: 1, Invalid: 0, Errors: 0, Skipped: 0" ]
}

@test "Pass when parsing a valid Kubernetes config YAML file and store cache" {
run mkdir cache
run bin/kubeconform -cache cache -summary fixtures/valid.yaml
[ "$status" -eq 0 ]
[ "$output" = "Summary: 1 resource found in 1 file - Valid: 1, Invalid: 0, Errors: 0, Skipped: 0" ]
[ "`ls cache/ | wc -l`" -eq 1 ]
}

@test "Fail when cache folder does not exist" {
run bin/kubeconform -cache cache_does_not_exist -summary fixtures/valid.yaml
[ "$status" -eq 1 ]
[ "$output" = "failed opening cache folder cache_does_not_exist: stat cache_does_not_exist: no such file or directory" ]
}
1 change: 1 addition & 0 deletions cmd/kubeconform/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ func realMain() int {
}

v, err := validator.New(cfg.SchemaLocations, validator.Opts{
Cache: cfg.Cache,
SkipTLS: cfg.SkipTLS,
SkipKinds: cfg.SkipKinds,
RejectKinds: cfg.RejectKinds,
Expand Down
6 changes: 6 additions & 0 deletions pkg/cache/cache.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package cache

type Cache interface {
Get(resourceKind, resourceAPIVersion, k8sVersion string) (interface{}, error)
Set(resourceKind, resourceAPIVersion, k8sVersion string, schema interface{}) error
}
49 changes: 49 additions & 0 deletions pkg/cache/inmemory.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package cache

import (
"fmt"
"sync"
)

// SchemaCache is a cache for downloaded schemas, so each file is only retrieved once
// It is different from pkg/registry/http_cache.go in that:
// - This cache caches the parsed Schemas
type inMemory struct {
sync.RWMutex
schemas map[string]interface{}
}

// New creates a new cache for downloaded schemas
func NewInMemoryCache() Cache {
return &inMemory{
schemas: map[string]interface{}{},
}
}

func key(resourceKind, resourceAPIVersion, k8sVersion string) string {
return fmt.Sprintf("%s-%s-%s", resourceKind, resourceAPIVersion, k8sVersion)
}

// Get retrieves the JSON schema given a resource signature
func (c *inMemory) Get(resourceKind, resourceAPIVersion, k8sVersion string) (interface{}, error) {
k := key(resourceKind, resourceAPIVersion, k8sVersion)
c.RLock()
defer c.RUnlock()
schema, ok := c.schemas[k]

if ok == false {
return nil, fmt.Errorf("schema not found in in-memory cache")
}

return schema, nil
}

// Set adds a JSON schema to the schema cache
func (c *inMemory) Set(resourceKind, resourceAPIVersion, k8sVersion string, schema interface{}) error {
k := key(resourceKind, resourceAPIVersion, k8sVersion)
c.Lock()
defer c.Unlock()
c.schemas[k] = schema

return nil
}
48 changes: 48 additions & 0 deletions pkg/cache/ondisk.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package cache

import (
"crypto/md5"
"encoding/hex"
"fmt"
"io/ioutil"
"os"
"path"
"sync"
)

type onDisk struct {
sync.RWMutex
folder string
}

// New creates a new cache for downloaded schemas
func NewOnDiskCache(cache string) Cache {
return &onDisk{
folder: cache,
}
}

func cachePath(folder, resourceKind, resourceAPIVersion, k8sVersion string) string {
hash := md5.Sum([]byte(fmt.Sprintf("%s-%s-%s", resourceKind, resourceAPIVersion, k8sVersion)))
return path.Join(folder, hex.EncodeToString(hash[:]))
}

// Get retrieves the JSON schema given a resource signature
func (c *onDisk) Get(resourceKind, resourceAPIVersion, k8sVersion string) (interface{}, error) {
c.RLock()
defer c.RUnlock()

f, err := os.Open(cachePath(c.folder, resourceKind, resourceAPIVersion, k8sVersion))
if err != nil {
return nil, err
}

return ioutil.ReadAll(f)
}

// Set adds a JSON schema to the schema cache
func (c *onDisk) Set(resourceKind, resourceAPIVersion, k8sVersion string, schema interface{}) error {
c.Lock()
defer c.Unlock()
return ioutil.WriteFile(cachePath(c.folder, resourceKind, resourceAPIVersion, k8sVersion), schema.([]byte), 0644)
}
42 changes: 0 additions & 42 deletions pkg/cache/schemacache.go

This file was deleted.

2 changes: 2 additions & 0 deletions pkg/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
)

type Config struct {
Cache string
CPUProfileFile string
ExitOnError bool
Files []string
Expand Down Expand Up @@ -75,6 +76,7 @@ func FromFlags(progName string, args []string) (Config, string, error) {
flags.StringVar(&c.OutputFormat, "output", "text", "output format - json, tap, text")
flags.BoolVar(&c.Verbose, "verbose", false, "print results for all resources (ignored for tap output)")
flags.BoolVar(&c.SkipTLS, "insecure-skip-tls-verify", false, "disable verification of the server's SSL certificate. This will make your HTTPS connections insecure")
flags.StringVar(&c.Cache, "cache", "", "cache schemas downloaded via HTTP to this folder")
flags.StringVar(&c.CPUProfileFile, "cpu-prof", "", "debug - log CPU profiling to file")
flags.BoolVar(&c.Help, "h", false, "show help information")
flags.Usage = func() {
Expand Down
34 changes: 32 additions & 2 deletions pkg/registry/http.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,10 @@ import (
"fmt"
"io/ioutil"
"net/http"
"os"
"time"

"github.com/yannh/kubeconform/pkg/cache"
)

type httpGetter interface {
Expand All @@ -16,10 +19,11 @@ type httpGetter interface {
type SchemaRegistry struct {
c httpGetter
schemaPathTemplate string
cache cache.Cache
strict bool
}

func newHTTPRegistry(schemaPathTemplate string, strict bool, skipTLS bool) *SchemaRegistry {
func newHTTPRegistry(schemaPathTemplate string, cacheFolder string, strict bool, skipTLS bool) (*SchemaRegistry, error) {
reghttp := &http.Transport{
MaxIdleConns: 100,
IdleConnTimeout: 3 * time.Second,
Expand All @@ -30,11 +34,25 @@ func newHTTPRegistry(schemaPathTemplate string, strict bool, skipTLS bool) *Sche
reghttp.TLSClientConfig = &tls.Config{InsecureSkipVerify: true}
}

var filecache cache.Cache = nil
if cacheFolder != "" {
fi, err := os.Stat(cacheFolder)
if err != nil {
return nil, fmt.Errorf("failed opening cache folder %s: %s", cacheFolder, err)
}
if !fi.IsDir() {
return nil, fmt.Errorf("cache folder %s is not a directory", err)
}

filecache = cache.NewOnDiskCache(cacheFolder)
}

return &SchemaRegistry{
c: &http.Client{Transport: reghttp},
schemaPathTemplate: schemaPathTemplate,
cache: filecache,
strict: strict,
}
}, nil
}

// DownloadSchema downloads the schema for a particular resource from an HTTP server
Expand All @@ -44,6 +62,12 @@ func (r SchemaRegistry) DownloadSchema(resourceKind, resourceAPIVersion, k8sVers
return nil, err
}

if r.cache != nil {
if b, err := r.cache.Get(resourceKind, resourceAPIVersion, k8sVersion); err == nil {
return b.([]byte), nil
}
}

resp, err := r.c.Get(url)
if err != nil {
return nil, fmt.Errorf("failed downloading schema at %s: %s", url, err)
Expand All @@ -63,5 +87,11 @@ func (r SchemaRegistry) DownloadSchema(resourceKind, resourceAPIVersion, k8sVers
return nil, fmt.Errorf("failed downloading schema at %s: %s", url, err)
}

if r.cache != nil {
if err := r.cache.Set(resourceKind, resourceAPIVersion, k8sVersion, body); err != nil {
return nil, fmt.Errorf("failed writing schema to cache: %s", err)
}
}

return body, nil
}
4 changes: 2 additions & 2 deletions pkg/registry/local.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,11 @@ type LocalRegistry struct {
}

// NewLocalSchemas creates a new "registry", that will serve schemas from files, given a list of schema filenames
func newLocalRegistry(pathTemplate string, strict bool) *LocalRegistry {
func newLocalRegistry(pathTemplate string, strict bool) (*LocalRegistry, error) {
return &LocalRegistry{
pathTemplate,
strict,
}
}, nil
}

// DownloadSchema retrieves the schema from a file for the resource
Expand Down
6 changes: 3 additions & 3 deletions pkg/registry/registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ func schemaPath(tpl, resourceKind, resourceAPIVersion, k8sVersion string, strict
return buf.String(), nil
}

func New(schemaLocation string, strict bool, skipTLS bool) (Registry, error) {
func New(schemaLocation string, cache string, strict bool, skipTLS bool) (Registry, error) {
if !strings.HasSuffix(schemaLocation, "json") { // If we dont specify a full templated path, we assume the paths of kubernetesjsonschema.dev
schemaLocation += "/{{ .NormalizedKubernetesVersion }}-standalone{{ .StrictSuffix }}/{{ .ResourceKind }}{{ .KindSuffix }}.json"
}
Expand All @@ -90,8 +90,8 @@ func New(schemaLocation string, strict bool, skipTLS bool) (Registry, error) {
}

if strings.HasPrefix(schemaLocation, "http") {
return newHTTPRegistry(schemaLocation, strict, skipTLS), nil
return newHTTPRegistry(schemaLocation, cache, strict, skipTLS)
}

return newLocalRegistry(schemaLocation, strict), nil
return newLocalRegistry(schemaLocation, strict)
}
17 changes: 10 additions & 7 deletions pkg/validator/validator.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ type Validator interface {

// Opts contains a set of options for the validator.
type Opts struct {
Cache string // Cache schemas downloaded via HTTP to this folder
SkipTLS bool // skip TLS validation when downloading from an HTTP Schema Registry
SkipKinds map[string]struct{} // List of resource Kinds to ignore
RejectKinds map[string]struct{} // List of resource Kinds to reject
Expand All @@ -59,7 +60,7 @@ func New(schemaLocations []string, opts Opts) (Validator, error) {

registries := []registry.Registry{}
for _, schemaLocation := range schemaLocations {
reg, err := registry.New(schemaLocation, opts.Strict, opts.SkipTLS)
reg, err := registry.New(schemaLocation, opts.Cache, opts.Strict, opts.SkipTLS)
if err != nil {
return nil, err
}
Expand All @@ -80,14 +81,14 @@ func New(schemaLocations []string, opts Opts) (Validator, error) {
return &v{
opts: opts,
schemaDownload: downloadSchema,
schemaCache: cache.New(),
schemaCache: cache.NewInMemoryCache(),
regs: registries,
}, nil
}

type v struct {
opts Opts
schemaCache *cache.SchemaCache
schemaCache cache.Cache
schemaDownload func(registries []registry.Registry, kind, version, k8sVersion string) (*gojsonschema.Schema, error)
regs []registry.Registry
}
Expand Down Expand Up @@ -133,11 +134,13 @@ func (val *v) ValidateResource(res resource.Resource) Result {

cached := false
var schema *gojsonschema.Schema
cacheKey := ""

if val.schemaCache != nil {
cacheKey = cache.Key(sig.Kind, sig.Version, val.opts.KubernetesVersion)
schema, cached = val.schemaCache.Get(cacheKey)
s, err := val.schemaCache.Get(sig.Kind, sig.Version, val.opts.KubernetesVersion)
if err == nil {
cached = true
schema = s.(*gojsonschema.Schema)
}
}

if !cached {
Expand All @@ -146,7 +149,7 @@ func (val *v) ValidateResource(res resource.Resource) Result {
}

if val.schemaCache != nil {
val.schemaCache.Set(cacheKey, schema)
val.schemaCache.Set(sig.Kind, sig.Version, val.opts.KubernetesVersion, schema)
}
}

Expand Down