Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion cmd/crossplane/validate/cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import (

"github.com/alecthomas/kong"
"github.com/spf13/afero"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"

"github.com/crossplane/crossplane-runtime/v2/pkg/errors"
"github.com/crossplane/crossplane-runtime/v2/pkg/logging"
Expand Down Expand Up @@ -80,6 +81,7 @@ type Cmd struct {
CleanCache bool `help:"Clean the cache directory before downloading package schemas."`
CrossplaneImage string `default:"xpkg.crossplane.io/crossplane/crossplane:stable" help:"Specify the Crossplane image for validating built-in schemas."`
ErrorOnMissingSchemas bool `default:"false" help:"Return non zero exit code if missing schemas."`
OldResources string `help:"Previous resource state for CEL rules that reference oldSelf."`
// rendererFlag.Decode rejects unknown formats, which is what Kong's
// "enum" tag would normally enforce — but enum doesn't apply to
// MapperValue-backed fields. The help text is the user-facing list
Expand Down Expand Up @@ -132,6 +134,21 @@ func (c *Cmd) Run(k *kong.Context, _ logging.Logger) error {
return errors.Wrapf(err, "cannot load resources from %q", c.Resources)
}

// Load old resources, if provided, so CEL transition rules can compare the
// resources under validation against their previous state.
var oldResources []*unstructured.Unstructured
if c.OldResources != "" {
oldResourceLoader, err := load.NewLoader(c.OldResources)
if err != nil {
return errors.Wrapf(err, "cannot load old resources from %q", c.OldResources)
}

oldResources, err = oldResourceLoader.Load()
if err != nil {
return errors.Wrapf(err, "cannot load old resources from %q", c.OldResources)
}
}

if strings.HasPrefix(c.CacheDir, "~/") {
homeDir, _ := os.UserHomeDir()
c.CacheDir = filepath.Join(homeDir, c.CacheDir[2:])
Expand All @@ -151,7 +168,7 @@ func (c *Cmd) Run(k *kong.Context, _ logging.Logger) error {

// Validate resources against schemas, render in the requested format,
// and return a CLI-shaped error when validation didn't pass.
result, err := pkgvalidate.SchemaValidate(context.Background(), resources, m.crds)
result, err := pkgvalidate.SchemaValidate(context.Background(), resources, oldResources, m.crds)
if err != nil {
return errors.Wrapf(err, "cannot validate resources")
}
Expand Down
30 changes: 30 additions & 0 deletions cmd/crossplane/validate/help/validate.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,29 @@ spec:
# message: "replicas should be in between minReplicas and maxReplicas."
```

### Validate CEL transition rules against previous state

Some CEL rules are *transition rules*: they reference `oldSelf` to compare a
resource against its previous state, for example to enforce that a field is
immutable:

```yaml
# spec.x-kubernetes-validations:
# - rule: "self.param == oldSelf.param"
# message: "param is immutable"
```

These rules only fire when a previous state is available. Supply it with
`--old-resources`, which accepts the same comma-separated list of files or
directories as the resource argument. Old resources are matched to the
resources under validation by API version, kind, name, and namespace. A
resource with no matching old state is validated as a create, so its transition
rules are skipped:

```shell
crossplane resource validate extensionsDir/ resourceDir/ --old-resources oldResourceDir/
```

## Validate against a directory of schemas

`validate` can also take a directory of schema YAML files to use for
Expand Down Expand Up @@ -156,3 +179,10 @@ Use a custom cache directory and clean it before downloading schemas:
```shell
crossplane resource validate extensionsDir/ resourceDir/ --cache-dir .cache --clean-cache
```

Validate resources, using a directory of previous states so CEL rules that
reference `oldSelf` (such as immutability constraints) can be evaluated:

```shell
crossplane resource validate extensionsDir/ resourceDir/ --old-resources oldResourceDir/
```
44 changes: 41 additions & 3 deletions pkg/validate/validate.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ package validate

import (
"context"
"fmt"

ext "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions"
extv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
Expand All @@ -36,6 +37,14 @@ import (

// SchemaValidate performs schema validation and returns structured results.
//
// oldResources holds the previous state of the resources under validation so
// that CEL transition rules — those referencing oldSelf, such as immutability
// constraints — can be evaluated. They are matched to resources by
// GroupVersionKind, name, and namespace; a resource with no matching old state
// is validated with a nil old object, so its transition rules are skipped
// exactly as they are on a Kubernetes create. Pass nil when there is no
// previous state.
//
// This is the processing-only API: no I/O is performed, allowing programmatic
// consumers to inspect the result directly or hand it to a renderer. The
// returned error is non-nil only for setup failures (for example, a CRD that
Expand All @@ -45,22 +54,47 @@ import (
// Caller-owned resources are not mutated. SchemaValidate operates on a deep
// copy of each input, so the structural defaulting and unknown-field pruning
// it performs internally are not visible after the call returns.
func SchemaValidate(ctx context.Context, resources []*unstructured.Unstructured, crds []*extv1.CustomResourceDefinition) (*ValidationResult, error) {
func SchemaValidate(ctx context.Context, resources []*unstructured.Unstructured, oldResources []*unstructured.Unstructured, crds []*extv1.CustomResourceDefinition) (*ValidationResult, error) {
schemaValidators, structurals, err := newValidatorsAndStructurals(crds)
if err != nil {
return nil, errors.Wrap(err, "cannot create schema validators")
}

// Index old resources by GVK+name+namespace so each resource can be paired
// with its previous state. On duplicate keys the last entry wins.
oldByKey := make(map[string]*unstructured.Unstructured, len(oldResources))
for _, o := range oldResources {
oldByKey[resourceKey(o)] = o
}

result := &ValidationResult{
Resources: make([]ResourceValidationResult, 0, len(resources)),
}
for _, r := range resources {
result.Resources = append(result.Resources, validateResource(ctx, r, schemaValidators, structurals, crds))
result.Resources = append(result.Resources, validateResource(ctx, r, objectOf(oldByKey[resourceKey(r)]), schemaValidators, structurals, crds))
}
result.Summary = computeSummary(result.Resources)
return result, nil
}

// resourceKey identifies a resource by GroupVersionKind, name, and namespace.
// It is used to match a resource under validation to its previous state so CEL
// transition rules see the right old object.
func resourceKey(r *unstructured.Unstructured) string {
gvk := r.GetObjectKind().GroupVersionKind()
return fmt.Sprintf("%s-%s-%s", gvk.String(), getResourceName(r), r.GetNamespace())
}

// objectOf returns the unstructured content of u, or nil when u is nil. It
// keeps the nil check for an unmatched old resource in one place so callers can
// pass the result straight to the CEL validator's oldObject argument.
func objectOf(u *unstructured.Unstructured) map[string]any {
if u == nil {
return nil
}
return u.Object
}

// validateResource runs every check (schema, CEL, unknown fields, defaulting)
// against a single resource and returns its ResourceValidationResult. It is
// the per-resource decomposition of SchemaValidate; pulling it out keeps the
Expand All @@ -72,6 +106,7 @@ func SchemaValidate(ctx context.Context, resources []*unstructured.Unstructured,
func validateResource(
ctx context.Context,
in *unstructured.Unstructured,
oldObject map[string]any,
schemaValidators map[runtimeschema.GroupVersionKind]*validation.SchemaValidator,
structurals map[runtimeschema.GroupVersionKind]*schema.Structural,
crds []*extv1.CustomResourceDefinition,
Expand Down Expand Up @@ -117,8 +152,11 @@ func validateResource(
rvr.Errors = append(rvr.Errors, fieldErrorToFieldValidationError(e, FieldErrorTypeUnknownField))
}

// oldObject feeds CEL transition rules (those referencing oldSelf). It is
// nil when no previous state was supplied for this resource, in which case
// the CEL validator skips transition rules — matching a Kubernetes create.
celValidator := cel.NewValidator(s, true, celconfig.PerCallLimit)
celErrs, _ := celValidator.Validate(ctx, nil, s, r.Object, nil, celconfig.PerCallLimit)
celErrs, _ := celValidator.Validate(ctx, nil, s, r.Object, oldObject, celconfig.PerCallLimit)
for _, e := range celErrs {
rvr.Errors = append(rvr.Errors, fieldErrorToFieldValidationError(e, FieldErrorTypeCEL))
}
Expand Down
86 changes: 83 additions & 3 deletions pkg/validate/validate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,45 @@ var testCRDWithCEL = &extv1.CustomResourceDefinition{
},
}

// testCRDWithTransition has a CEL transition rule: spec.param is immutable.
// The rule references oldSelf, so it only fires when a previous state (an old
// resource) is supplied; on a create (nil old object) it is skipped.
var testCRDWithTransition = &extv1.CustomResourceDefinition{
TypeMeta: metav1.TypeMeta{
APIVersion: "apiextensions.k8s.io/v1",
Kind: "CustomResourceDefinition",
},
ObjectMeta: metav1.ObjectMeta{Name: "test-transition"},
Spec: extv1.CustomResourceDefinitionSpec{
Group: "test.org",
Names: extv1.CustomResourceDefinitionNames{
Kind: "TestTransition", ListKind: "TestTransitionList", Plural: "testtransitions", Singular: "testtransition",
},
Scope: "Cluster",
Versions: []extv1.CustomResourceDefinitionVersion{{
Name: "v1alpha1", Served: true, Storage: true,
Schema: &extv1.CustomResourceValidation{
OpenAPIV3Schema: &extv1.JSONSchemaProps{
Type: "object",
Properties: map[string]extv1.JSONSchemaProps{
"spec": {
Type: "object",
XValidations: extv1.ValidationRules{{
Rule: "self.param == oldSelf.param",
Message: "param is immutable",
}},
Properties: map[string]extv1.JSONSchemaProps{
"param": {Type: "string"},
},
Required: []string{"param"},
},
},
},
},
}},
},
}

// testCRDNoMatchingVersion is a CRD that shares group+kind with testCRD but
// only declares v1beta1. When used BEFORE testCRD in the crds slice,
// applyDefaults matches it first and fails because v1alpha1 is missing.
Expand Down Expand Up @@ -181,10 +220,23 @@ func TestSchemaValidate(t *testing.T) {
"metadata": map[string]any{"name": "def-fail"},
"spec": map[string]any{"replicas": int64(1)},
}}
transitionResource := &unstructured.Unstructured{Object: map[string]any{
"apiVersion": "test.org/v1alpha1",
"kind": "TestTransition",
"metadata": map[string]any{"name": "test"},
"spec": map[string]any{"param": "changed-value"},
}}
transitionOldResource := &unstructured.Unstructured{Object: map[string]any{
"apiVersion": "test.org/v1alpha1",
"kind": "TestTransition",
"metadata": map[string]any{"name": "test"},
"spec": map[string]any{"param": "original-value"},
}}

type args struct {
resources []*unstructured.Unstructured
crds []*extv1.CustomResourceDefinition
resources []*unstructured.Unstructured
oldResources []*unstructured.Unstructured
crds []*extv1.CustomResourceDefinition
}
// expect declares everything we assert about a single resource's result:
// its Status and the exact set of FieldValidationErrors. Message and
Expand Down Expand Up @@ -325,6 +377,34 @@ func TestSchemaValidate(t *testing.T) {
perRes: nil,
},
},
"TransitionRuleViolatedWithOldResource": {
reason: "When a matching old resource is supplied, an immutability CEL transition rule (self.param == oldSelf.param) fires and the resource is Invalid with a cel-type error.",
args: args{
resources: []*unstructured.Unstructured{transitionResource},
oldResources: []*unstructured.Unstructured{transitionOldResource},
crds: []*extv1.CustomResourceDefinition{testCRDWithTransition},
},
want: want{
summary: ValidationSummary{Total: 1, Invalid: 1},
perRes: []expect{{
status: ValidationStatusInvalid,
errors: []FieldValidationError{
{Type: FieldErrorTypeCEL, Field: "spec"},
},
}},
},
},
"TransitionRuleSkippedWithoutOldResource": {
reason: "Without an old resource, the transition rule references oldSelf and is skipped (as on a create), so the same resource is Valid. Guards backward compatibility for callers that supply no old resources.",
args: args{
resources: []*unstructured.Unstructured{transitionResource},
crds: []*extv1.CustomResourceDefinition{testCRDWithTransition},
},
want: want{
summary: ValidationSummary{Total: 1, Valid: 1},
perRes: []expect{{status: ValidationStatusValid}},
},
},
"MixedOrder": {
reason: "Resources are returned in input order with their respective statuses.",
args: args{
Expand Down Expand Up @@ -353,7 +433,7 @@ func TestSchemaValidate(t *testing.T) {

for name, tc := range cases {
t.Run(name, func(t *testing.T) {
result, err := SchemaValidate(t.Context(), tc.args.resources, tc.args.crds)
result, err := SchemaValidate(t.Context(), tc.args.resources, tc.args.oldResources, tc.args.crds)
if (err != nil) != tc.want.wantErr {
t.Fatalf("%s\nSchemaValidate() err = %v, wantErr = %v", tc.reason, err, tc.want.wantErr)
}
Expand Down