Skip to content

Commit

Permalink
Merge pull request #5326 from ezgidemirel/validate-cel
Browse files Browse the repository at this point in the history
Add CEL validation to `validate` subcommand
  • Loading branch information
phisco committed Feb 2, 2024
2 parents 5663b10 + 7edf5fe commit ad57d45
Show file tree
Hide file tree
Showing 5 changed files with 207 additions and 47 deletions.
2 changes: 0 additions & 2 deletions cmd/crank/beta/validate/cache.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ limitations under the License.
package validate

import (
"fmt"
"os"
"path/filepath"
"strings"
Expand Down Expand Up @@ -84,7 +83,6 @@ func (c *LocalCache) Init() error {

// Flush removes the cache directory
func (c *LocalCache) Flush() error {
fmt.Println("flushing cache directory: ", c.cacheDir)
return c.fs.RemoveAll(c.cacheDir)
}

Expand Down
6 changes: 3 additions & 3 deletions cmd/crank/beta/validate/cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ func (c *Cmd) AfterApply() error {
}

// Run validate.
func (c *Cmd) Run(_ *kong.Context, _ logging.Logger) error { //nolint:gocyclo // stdin check makes it over the top
func (c *Cmd) Run(k *kong.Context, _ logging.Logger) error { //nolint:gocyclo // stdin check makes it over the top
if c.Resources == "-" && c.Extensions == "-" {
return errors.New("cannot use stdin for both extensions and resources")
}
Expand Down Expand Up @@ -117,7 +117,7 @@ func (c *Cmd) Run(_ *kong.Context, _ logging.Logger) error { //nolint:gocyclo //
c.CacheDir = filepath.Join(currentPath, c.CacheDir)
}

m := NewManager(c.CacheDir, c.fs)
m := NewManager(c.CacheDir, c.fs, k.Stdout)

// Convert XRDs/CRDs to CRDs and add package dependencies
if err := m.PrepExtensions(extensions); err != nil {
Expand All @@ -130,7 +130,7 @@ func (c *Cmd) Run(_ *kong.Context, _ logging.Logger) error { //nolint:gocyclo //
}

// Validate resources against schemas
if err := SchemaValidation(resources, m.crds, c.SkipSuccessResults); err != nil {
if err := SchemaValidation(resources, m.crds, c.SkipSuccessResults, k.Stdout); err != nil {
return errors.Wrapf(err, "cannot validate resources")
}

Expand Down
10 changes: 7 additions & 3 deletions cmd/crank/beta/validate/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ package validate

import (
"fmt"
"io"

"github.com/spf13/afero"
apiextv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
Expand Down Expand Up @@ -47,14 +48,15 @@ const (
type Manager struct {
fetcher ImageFetcher
cache Cache
writer io.Writer

crds []*apiextv1.CustomResourceDefinition
deps map[string]bool // One level dependency images
confs map[string]bool // Configuration images
}

// NewManager returns a new Manager
func NewManager(cacheDir string, fs afero.Fs) *Manager {
func NewManager(cacheDir string, fs afero.Fs, w io.Writer) *Manager {
m := &Manager{}

m.cache = &LocalCache{
Expand All @@ -63,7 +65,7 @@ func NewManager(cacheDir string, fs afero.Fs) *Manager {
}

m.fetcher = &Fetcher{}

m.writer = w
m.crds = make([]*apiextv1.CustomResourceDefinition, 0)
m.deps = make(map[string]bool)
m.confs = make(map[string]bool)
Expand Down Expand Up @@ -218,7 +220,9 @@ func (m *Manager) cacheDependencies() error {
continue
}

fmt.Printf("package schemas does not exist, downloading: %s\n", image)
if _, err := fmt.Fprintln(m.writer, "package schemas does not exist, downloading: ", image); err != nil {
return errors.Wrapf(err, errWriteOutput)
}

layer, err := m.fetcher.FetchBaseLayer(image)
if err != nil {
Expand Down
120 changes: 83 additions & 37 deletions cmd/crank/beta/validate/validate.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,102 +17,148 @@ limitations under the License.
package validate

import (
"context"
"fmt"
"io"

ext "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions"
extv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
"k8s.io/apiextensions-apiserver/pkg/apiserver/schema"
"k8s.io/apiextensions-apiserver/pkg/apiserver/schema/cel"
"k8s.io/apiextensions-apiserver/pkg/apiserver/validation"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime/schema"
runtimeschema "k8s.io/apimachinery/pkg/runtime/schema"
celconfig "k8s.io/apiserver/pkg/apis/cel"

"github.com/crossplane/crossplane-runtime/pkg/errors"

"github.com/crossplane/crossplane/internal/controller/apiextensions/composite"
)

func newValidators(crds []*extv1.CustomResourceDefinition) (map[schema.GroupVersionKind][]validation.SchemaValidator, error) {
validators := map[schema.GroupVersionKind][]validation.SchemaValidator{}
const (
errWriteOutput = "cannot write output"
)

func newValidatorsAndStructurals(crds []*extv1.CustomResourceDefinition) (map[runtimeschema.GroupVersionKind][]*validation.SchemaValidator, map[runtimeschema.GroupVersionKind]*schema.Structural, error) {
validators := map[runtimeschema.GroupVersionKind][]*validation.SchemaValidator{}
structurals := map[runtimeschema.GroupVersionKind]*schema.Structural{}

for i := range crds {
internal := &ext.CustomResourceDefinition{}
if err := extv1.Convert_v1_CustomResourceDefinition_To_apiextensions_CustomResourceDefinition(crds[i], internal, nil); err != nil {
return nil, err
return nil, nil, err
}

// Top-level and per-version schemas are mutually exclusive. Therefore, we will use both if they are present.
// Top-level and per-version schemas are mutually exclusive.
for _, ver := range internal.Spec.Versions {
var sv validation.SchemaValidator
var err error

gvk := schema.GroupVersionKind{
gvk := runtimeschema.GroupVersionKind{
Group: internal.Spec.Group,
Version: ver.Name,
Kind: internal.Spec.Names.Kind,
}

// Version specific validation rules
if ver.Schema != nil && ver.Schema.OpenAPIV3Schema != nil {
sv, _, err = validation.NewSchemaValidator(ver.Schema.OpenAPIV3Schema)
if err != nil {
return nil, err
}
var s *ext.JSONSchemaProps
switch {
case internal.Spec.Validation != nil:
s = internal.Spec.Validation.OpenAPIV3Schema
case ver.Schema != nil && ver.Schema.OpenAPIV3Schema != nil:
s = ver.Schema.OpenAPIV3Schema
default:
// TODO log a warning here, it should never happen
continue
}

validators[gvk] = append(validators[gvk], sv)
sv, _, err = validation.NewSchemaValidator(s)
if err != nil {
return nil, nil, err
}

// Top level validation rules
if internal.Spec.Validation != nil {
sv, _, err = validation.NewSchemaValidator(internal.Spec.Validation.OpenAPIV3Schema)
if err != nil {
return nil, err
}
validators[gvk] = append(validators[gvk], &sv)

validators[gvk] = append(validators[gvk], sv)
structural, err := schema.NewStructural(s)
if err != nil {
return nil, nil, err
}

structurals[gvk] = structural
}
}

return validators, nil
return validators, structurals, nil
}

// SchemaValidation validates the resources against the given CRDs
func SchemaValidation(resources []*unstructured.Unstructured, crds []*extv1.CustomResourceDefinition, skipSuccessLogs bool) error {
schemaValidators, err := newValidators(crds)
func SchemaValidation(resources []*unstructured.Unstructured, crds []*extv1.CustomResourceDefinition, skipSuccessLogs bool, w io.Writer) error { //nolint:gocyclo // printing the output increases the cyclomatic complexity a little bit
schemaValidators, structurals, err := newValidatorsAndStructurals(crds)
if err != nil {
return errors.Wrap(err, "cannot create schema validators")
}

failure, warning := 0, 0
failure, missingSchemas := 0, 0

for i, r := range resources {
resourceValidators, ok := schemaValidators[r.GetObjectKind().GroupVersionKind()]
gvk := r.GetObjectKind().GroupVersionKind()
sv, ok := schemaValidators[gvk]
if !ok {
warning++
fmt.Println("[!] could not find CRD/XRD for: " + r.GroupVersionKind().String())
missingSchemas++
if _, err := fmt.Fprintf(w, "[!] could not find CRD/XRD for: %s\n", r.GroupVersionKind().String()); err != nil {
return errors.Wrap(err, errWriteOutput)
}

continue
}

rf := 0
for _, v := range resourceValidators {
re := v.Validate(&resources[i])
for _, e := range re.Errors {

for _, v := range sv {
re := validation.ValidateCustomResource(nil, r, *v)
for _, e := range re {
rf++
fmt.Printf("[x] validation error %s, %s : %s\n", r.GroupVersionKind().String(), r.GetAnnotations()[composite.AnnotationKeyCompositionResourceName], e.Error())
if _, err := fmt.Fprintf(w, "[x] schema validation error %s, %s : %s\n", r.GroupVersionKind().String(), getResourceName(r), e.Error()); err != nil {
return errors.Wrap(err, errWriteOutput)
}
}
}

if rf == 0 && !skipSuccessLogs {
fmt.Printf("[✓] %s, %s validated successfully\n", r.GroupVersionKind().String(), r.GetAnnotations()[composite.AnnotationKeyCompositionResourceName])
} else {
failure++
s := structurals[gvk] // if we have a schema validator, we should also have a structural

celValidator := cel.NewValidator(s, true, celconfig.PerCallLimit)
re, _ = celValidator.Validate(context.TODO(), nil, s, resources[i].Object, nil, celconfig.PerCallLimit)
for _, e := range re {
rf++
if _, err := fmt.Fprintf(w, "[x] CEL validation error %s, %s : %s\n", r.GroupVersionKind().String(), getResourceName(r), e.Error()); err != nil {
return errors.Wrap(err, errWriteOutput)
}
}

if rf == 0 && !skipSuccessLogs {
if _, err := fmt.Fprintf(w, "[✓] %s, %s validated successfully\n", r.GroupVersionKind().String(), getResourceName(r)); err != nil {
return errors.Wrap(err, errWriteOutput)
}
} else {
failure++
}
}
}

fmt.Printf("%d error, %d warning, %d success cases\n", failure, warning, len(resources)-failure-warning)
if _, err := fmt.Fprintf(w, "Total %d resources: %d missing schemas, %d success cases, %d failure cases\n", len(resources), missingSchemas, len(resources)-failure-missingSchemas, failure); err != nil {
return errors.Wrap(err, errWriteOutput)
}

if failure > 0 {
return errors.New("could not validate all resources")
}

return nil
}

func getResourceName(r *unstructured.Unstructured) string {
if r.GetName() != "" {
return r.GetName()
}

// fallback to composition resource name
return r.GetAnnotations()[composite.AnnotationKeyCompositionResourceName]
}

0 comments on commit ad57d45

Please sign in to comment.