diff --git a/cmd/crossplane/validate/cmd.go b/cmd/crossplane/validate/cmd.go index 5eefbfb2..40cec279 100644 --- a/cmd/crossplane/validate/cmd.go +++ b/cmd/crossplane/validate/cmd.go @@ -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" @@ -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 @@ -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:]) @@ -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") } diff --git a/cmd/crossplane/validate/help/validate.md b/cmd/crossplane/validate/help/validate.md index ba5f0076..af465d18 100644 --- a/cmd/crossplane/validate/help/validate.md +++ b/cmd/crossplane/validate/help/validate.md @@ -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 @@ -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/ +``` diff --git a/pkg/validate/validate.go b/pkg/validate/validate.go index ec1be1f6..3720543e 100644 --- a/pkg/validate/validate.go +++ b/pkg/validate/validate.go @@ -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" @@ -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 @@ -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 @@ -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, @@ -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)) } diff --git a/pkg/validate/validate_test.go b/pkg/validate/validate_test.go index 6b604ac5..0aded430 100644 --- a/pkg/validate/validate_test.go +++ b/pkg/validate/validate_test.go @@ -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. @@ -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 @@ -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{ @@ -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) }