Skip to content
Open
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
36 changes: 30 additions & 6 deletions 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 @@ -76,10 +77,11 @@ type Cmd struct {
Resources string `arg:"" help:"Resource sources as a comma-separated list of files, directories, or '-' for standard input."`

// Flags. Keep them in alphabetical order.
CacheDir string `default:"~/.crossplane/cache" help:"Absolute path to the cache directory for downloaded schemas." predictor:"directory"`
CacheDir string `default:"~/.crossplane/cache" help:"Absolute path to the cache directory for downloaded schemas." predictor:"directory"`
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."`
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 transition rules, as a comma-separated list of files, directories, or '-' for standard input."`
// 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 All @@ -106,8 +108,15 @@ func (c *Cmd) AfterApply() error {

// Run validate.
func (c *Cmd) Run(k *kong.Context, _ logging.Logger) error {
if c.Resources == "-" && c.Extensions == "-" {
return errors.New("cannot use stdin for both extensions and resources")
// stdin can only be consumed once, so at most one input may be "-".
stdinCount := 0
for _, in := range []string{c.Extensions, c.Resources, c.OldResources} {
if in == "-" {
stdinCount++
}
}
if stdinCount > 1 {
return errors.New("cannot use stdin for more than one input")
}

// Load all extensions
Expand All @@ -132,6 +141,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 +175,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/
```
30 changes: 30 additions & 0 deletions cmd/crossplane/validate/testdata/cmd/crd_transition.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
name: immutables.cmd.example.org
spec:
group: cmd.example.org
names:
kind: Immutable
listKind: ImmutableList
plural: immutables
singular: immutable
scope: Cluster
versions:
- name: v1alpha1
served: true
storage: true
schema:
openAPIV3Schema:
type: object
properties:
spec:
type: object
required:
- param
properties:
param:
type: string
x-kubernetes-validations:
- rule: "self.param == oldSelf.param"
message: "param is immutable"
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
apiVersion: cmd.example.org/v1alpha1
kind: Immutable
metadata:
name: immutable-instance
spec:
param: changed-value
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
apiVersion: cmd.example.org/v1alpha1
kind: Immutable
metadata:
name: immutable-instance
spec:
param: original-value
53 changes: 53 additions & 0 deletions cmd/crossplane/validate/validate_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,25 @@ func TestParseRejectsUnknownOutputFormat(t *testing.T) {
}
}

// TestRunRejectsMultipleStdinInputs asserts the guard in Run: stdin can only be
// consumed once, so at most one of extensions, resources, or --old-resources may
// be "-". The guard runs before any loader, so the file paths need not exist.
func TestRunRejectsMultipleStdinInputs(t *testing.T) {
cases := map[string][]string{
"ExtensionsAndResources": {"-", "-"},
"ResourcesAndOldResources": {"extensions.yaml", "-", "--old-resources=-"},
"ExtensionsAndOldResources": {"-", "resources.yaml", "--old-resources=-"},
}
for name, argv := range cases {
t.Run(name, func(t *testing.T) {
_, err := runCmd(t, append(argv, commonArgs...)...)
if err == nil || !strings.Contains(err.Error(), "stdin") {
t.Errorf("expected a stdin error, got: %v", err)
}
})
}
}

// TestRun drives the validate command end-to-end through Kong, against
// real fixture files and a pre-populated cache directory that keeps the
// run offline. Nothing is mocked; the case table covers
Expand Down Expand Up @@ -232,6 +251,40 @@ func TestRun(t *testing.T) {
}
},
},
"OldResourcesTransitionViolationExitsNonZero": {
reason: "With --old-resources supplying the previous state, a CEL transition rule (immutable field changed) fires: the resource is Invalid with a CEL error and the command exits non-zero.",
extensions: "testdata/cmd/crd_transition.yaml",
resources: "testdata/cmd/resources_transition_new.yaml",
extraArgs: []string{"--output=json", "--old-resources=testdata/cmd/resources_transition_old.yaml"},
wantErr: true,
assertJSON: func(t *testing.T, r *pkgvalidate.ValidationResult) {
t.Helper()
if r.Summary.Invalid != 1 {
t.Errorf("Summary.Invalid = %d; want 1", r.Summary.Invalid)
}
if len(r.Resources) != 1 || r.Resources[0].Status != pkgvalidate.ValidationStatusInvalid {
t.Errorf("Resources = %+v; want one Invalid entry", r.Resources)
}
if len(r.Resources[0].Errors) == 0 || r.Resources[0].Errors[0].Type != pkgvalidate.FieldErrorTypeCEL {
t.Errorf("Resources[0].Errors = %+v; want a CEL error", r.Resources[0].Errors)
}
},
},
"OldResourcesTransitionSkippedWithoutFlag": {
reason: "Without --old-resources the same resource is Valid: the transition rule references oldSelf and is skipped, exactly as on a create.",
extensions: "testdata/cmd/crd_transition.yaml",
resources: "testdata/cmd/resources_transition_new.yaml",
extraArgs: []string{"--output=json"},
assertJSON: func(t *testing.T, r *pkgvalidate.ValidationResult) {
t.Helper()
if r.Summary.Total != 1 || r.Summary.Valid != 1 {
t.Errorf("Summary = %+v; want Total=1 Valid=1", r.Summary)
}
if len(r.Resources) != 1 || r.Resources[0].Status != pkgvalidate.ValidationStatusValid {
t.Errorf("Resources = %+v; want one Valid entry", r.Resources)
}
},
},
}

for name, tc := range cases {
Expand Down
55 changes: 52 additions & 3 deletions pkg/validate/validate.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import (
"k8s.io/apiextensions-apiserver/pkg/apiserver/validation"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
runtimeschema "k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apimachinery/pkg/types"
"k8s.io/apimachinery/pkg/util/validation/field"
celconfig "k8s.io/apiserver/pkg/apis/cel"

Expand All @@ -45,22 +46,66 @@ 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) {
//
// 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.
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 identity so each resource can be paired with its
// previous state in O(1). On duplicate keys the last entry wins.
oldByKey := make(map[oldResourceKey]*unstructured.Unstructured, len(oldResources))
for _, o := range oldResources {
oldByKey[keyOf(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))
// Look up this resource's previous state, if supplied, so CEL transition
// rules (those referencing oldSelf) can be evaluated. With no match
// oldObject stays nil and transition rules are skipped, exactly as on a
// Kubernetes create.
var oldObject map[string]any
if old, ok := oldByKey[keyOf(r)]; ok {
oldObject = old.Object
}
result.Resources = append(result.Resources, validateResource(ctx, r, oldObject, schemaValidators, structurals, crds))
}
result.Summary = computeSummary(result.Resources)
return result, nil
}

// oldResourceKey identifies a resource for matching it to its previous state. It
// composes two native, comparable Kubernetes value types — a GroupVersionKind
// and a NamespacedName — so the key is unambiguous (unlike a joined string,
// where a name or namespace containing the separator could collide) and usable
// directly as a Go map key.
type oldResourceKey struct {
gvk runtimeschema.GroupVersionKind
name types.NamespacedName
}

// keyOf derives the oldResourceKey for a resource. Name resolution goes through
// getResourceName so the match is consistent with how validateResource reports
// the resource name, including its composition-resource-name annotation
// fallback.
func keyOf(r *unstructured.Unstructured) oldResourceKey {
return oldResourceKey{
gvk: r.GroupVersionKind(),
name: types.NamespacedName{Namespace: r.GetNamespace(), Name: getResourceName(r)},
}
}

// 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 +117,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 +163,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
Loading