Skip to content

feat: Add support for old resources in validate#166

Open
cychiang wants to merge 2 commits into
crossplane:mainfrom
cychiang:feat-add-validate-old-resources
Open

feat: Add support for old resources in validate#166
cychiang wants to merge 2 commits into
crossplane:mainfrom
cychiang:feat-add-validate-old-resources

Conversation

@cychiang

@cychiang cychiang commented Jun 28, 2026

Copy link
Copy Markdown
Contributor

Description of your changes

Fixes crossplane/crossplane#7532

To run e2e tests:

go test ./cmd/crossplane/validate/ -run 'TestRun/OldResources' -s 

I have:

Need help with this checklist? See the cheat sheet.

cychiang added 2 commits July 2, 2026 01:51
Thread previous resource state into CEL validation so transition rules that reference oldSelf (such as immutability constraints) are evaluated. Old resources are matched to resources by GVK, name, and namespace; a resource with no match is validated as a create, leaving its transition rules skipped.

Signed-off-by: Chuan-Yen Chiang <cychiang0823@gmail.com>
Signed-off-by: Chuan-Yen Chiang <cychiang0823@gmail.com>
@cychiang cychiang force-pushed the feat-add-validate-old-resources branch from df837b5 to 642a44a Compare July 1, 2026 23:54
@cychiang cychiang marked this pull request as ready for review July 2, 2026 00:02
@cychiang cychiang requested review from a team, jcogilvie and tampakrap as code owners July 2, 2026 00:02
@cychiang cychiang requested review from adamwg and removed request for a team July 2, 2026 00:02
@coderabbitai

coderabbitai Bot commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

This PR adds support for supplying previous resource state to crossplane resource validate via a new OldResources/--old-resources input. SchemaValidate is extended to index old resources by GVK/name/namespace and pass matched prior state into CEL validation, enabling transition rules referencing oldSelf. Documentation and tests are updated accordingly.

Changes

Old resources transition validation

Layer / File(s) Summary
SchemaValidate pairing and CEL invocation
pkg/validate/validate.go
SchemaValidate gains an oldResources parameter, indexes old objects by GVK/name/namespace, and passes the matched old object into validateResource and the CEL validator instead of always passing nil.
CLI flag and loader wiring
cmd/crossplane/validate/cmd.go
Adds an OldResources field to Cmd, conditionally loads old resources as *unstructured.Unstructured when set, and forwards them to pkgvalidate.SchemaValidate.
Tests and documentation
pkg/validate/validate_test.go, cmd/crossplane/validate/validate_integration_test.go, cmd/crossplane/validate/help/validate.md
Adds a CEL transition-rule CRD fixture, table-driven test cases for violation/skip scenarios, integration tests exercising --old-resources, and help documentation with examples describing the new flag.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Possibly related issues

Possibly related PRs

  • crossplane/cli#66: Both PRs modify the same SchemaValidate CEL validation pipeline that this PR further extends to accept old resources.

Suggested reviewers: jcogilvie, jbw976

Thanks for adding this — nicely closes the gap between create-only validation and real transition-rule testing! 🎉

🚥 Pre-merge checks | ✅ 6
✅ Passed checks (6 passed)
Check name Status Explanation
Title check ✅ Passed The title is concise, under 72 characters, and clearly describes adding old-resource support.
Description check ✅ Passed The description is directly related to the PR and mentions the fix, tests, and validation workflow.
Linked Issues check ✅ Passed The changes implement the requested old-resource flag, matching logic, and transition-rule validation behavior from #7532.
Out of Scope Changes check ✅ Passed The docs and tests support the same old-resource validation feature and do not appear unrelated.
Breaking Changes ✅ Passed PASS: cmd/crossplane/validate only adds optional OldResources/--old-resources; existing args and flags remain unchanged, so there’s no breaking removal or new required flag.
Feature Gate Requirement ✅ Passed The new transition-rule behavior is opt-in via the added --old-resources CLI flag, and this PR does not change apis/**.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
cmd/crossplane/validate/cmd.go (1)

110-113: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Thanks for adding this — but the stdin guard needs to grow with the new flag.

c.OldResources can also be "-" (it goes through the same load.NewLoader), but this check only guards against Resources+Extensions both being stdin. If a user runs something like ... resources.yaml - --old-resources - (or extensions+old-resources both -), two loaders will race to read the same stdin stream — one gets the real payload, the other silently gets nothing (or errors confusingly). Could we generalize the guard to cover all three inputs?

🐛 Proposed fix generalizing the stdin guard
-	if c.Resources == "-" && c.Extensions == "-" {
-		return errors.New("cannot use stdin for both extensions and resources")
-	}
+	stdinInputs := 0
+	for _, in := range []string{c.Resources, c.Extensions, c.OldResources} {
+		if in == "-" {
+			stdinInputs++
+		}
+	}
+	if stdinInputs > 1 {
+		return errors.New("cannot use stdin for more than one input")
+	}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cmd/crossplane/validate/cmd.go` around lines 110 - 113, The stdin guard in
Cmd.Run only checks Resources and Extensions, but OldResources also uses
load.NewLoader and can consume stdin. Update the validation in Cmd.Run to reject
any combination where more than one of Resources, Extensions, or OldResources is
"-" so only a single loader can read stdin, and keep the error message aligned
with this broader guard.
🧹 Nitpick comments (1)
cmd/crossplane/validate/cmd.go (1)

84-84: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider enriching the help text to match the sibling Resources/Extensions args.

Resources documents its accepted forms ("files, directories, or - for standard input"), but OldResources's help string doesn't mention format at all, even though it goes through the same loader. Spelling that out here would help users discover -/directory/comma-separated support without digging into the docs.

♻️ Proposed help text tweak
-	OldResources          string `help:"Previous resource state for evaluating CEL transition rules."`
+	OldResources          string `help:"Previous resource state for evaluating CEL transition rules, as a comma-separated list of files, directories, or '-' for standard input."`

As per path instructions, "Review CLI commands for proper flag handling, help text, and error messages."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cmd/crossplane/validate/cmd.go` at line 84, The OldResources flag help text
is too vague compared with Resources and Extensions. Update the OldResources
help string in the validate command definition to mention the accepted input
forms handled by the shared loader, such as files, directories, comma-separated
values, and - for standard input, so users can discover the supported formats
directly from the CLI help.

Source: Path instructions

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@pkg/validate/validate.go`:
- Around line 63-97: `resourceKey` is vulnerable to collisions because it
concatenates GVK, name, and namespace into a single string with hyphens, so
distinct resources can map to the same key and mispair old/new objects in
`Validate`; replace the string-based keying in `Validate` and `resourceKey` with
a struct or other unambiguous composite key built from `GroupVersionKind`,
`getResourceName`, and `GetNamespace`, and update the `oldByKey` map accordingly
so duplicate handling cannot silently match the wrong resource. If `fmt` is only
used by `resourceKey`, remove that import after the refactor.

---

Outside diff comments:
In `@cmd/crossplane/validate/cmd.go`:
- Around line 110-113: The stdin guard in Cmd.Run only checks Resources and
Extensions, but OldResources also uses load.NewLoader and can consume stdin.
Update the validation in Cmd.Run to reject any combination where more than one
of Resources, Extensions, or OldResources is "-" so only a single loader can
read stdin, and keep the error message aligned with this broader guard.

---

Nitpick comments:
In `@cmd/crossplane/validate/cmd.go`:
- Line 84: The OldResources flag help text is too vague compared with Resources
and Extensions. Update the OldResources help string in the validate command
definition to mention the accepted input forms handled by the shared loader,
such as files, directories, comma-separated values, and - for standard input, so
users can discover the supported formats directly from the CLI help.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 20f428bb-1258-4af1-831d-3f1b3de61f27

📥 Commits

Reviewing files that changed from the base of the PR and between 40c3db0 and 642a44a.

⛔ Files ignored due to path filters (3)
  • cmd/crossplane/validate/testdata/cmd/crd_transition.yaml is excluded by !**/testdata/** and included by **/*.yaml
  • cmd/crossplane/validate/testdata/cmd/resources_transition_new.yaml is excluded by !**/testdata/** and included by **/*.yaml
  • cmd/crossplane/validate/testdata/cmd/resources_transition_old.yaml is excluded by !**/testdata/** and included by **/*.yaml
📒 Files selected for processing (5)
  • cmd/crossplane/validate/cmd.go
  • cmd/crossplane/validate/help/validate.md
  • cmd/crossplane/validate/validate_integration_test.go
  • pkg/validate/validate.go
  • pkg/validate/validate_test.go

Comment thread pkg/validate/validate.go
Comment on lines +63 to +97
// 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
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Thanks for wiring this up — but resourceKey has a collision bug that can silently mismatch old/new resources.

fmt.Sprintf("%s-%s-%s", gvk.String(), name, namespace) concatenates name and namespace with a plain -, but Kubernetes names and namespaces can themselves contain hyphens. Two different resources can produce the identical key: e.g. name="baz-foo", namespace="bar" and name="baz", namespace="foo-bar" both yield ...-baz-foo-bar. Since duplicate keys "last one wins" (line 64-65), this could silently pair a resource with the wrong old object — either hiding a real transition-rule violation or falsely triggering one, with no error surfaced to the user.

Could we switch to a struct-based key so there's no encoding ambiguity at all?

🐛 Proposed fix using a struct key instead of string concatenation
-	oldByKey := make(map[string]*unstructured.Unstructured, len(oldResources))
+	oldByKey := make(map[resourceKey]*unstructured.Unstructured, len(oldResources))
 	for _, o := range oldResources {
-		oldByKey[resourceKey(o)] = o
+		oldByKey[keyOf(o)] = o
 	}
 
 	result := &ValidationResult{
 		Resources: make([]ResourceValidationResult, 0, len(resources)),
 	}
 	for _, r := range resources {
-		result.Resources = append(result.Resources, validateResource(ctx, r, objectOf(oldByKey[resourceKey(r)]), schemaValidators, structurals, crds))
+		result.Resources = append(result.Resources, validateResource(ctx, r, objectOf(oldByKey[keyOf(r)]), schemaValidators, structurals, crds))
 	}
 	result.Summary = computeSummary(result.Resources)
 	return result, nil
 }
 
-// resourceKey identifies a resource by GroupVersionKind, name, and namespace.
+// 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())
+type resourceKey struct {
+	gvk       runtimeschema.GroupVersionKind
+	name      string
+	namespace string
+}
+
+func keyOf(r *unstructured.Unstructured) resourceKey {
+	return resourceKey{
+		gvk:       r.GetObjectKind().GroupVersionKind(),
+		name:      getResourceName(r),
+		namespace: r.GetNamespace(),
+	}
 }

Note: if fmt isn't used elsewhere in the file after this change, the fmt import at line 21 should be dropped too.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// 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
}
// 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[resourceKey]*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, objectOf(oldByKey[keyOf(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.
type resourceKey struct {
gvk runtimeschema.GroupVersionKind
name string
namespace string
}
func keyOf(r *unstructured.Unstructured) resourceKey {
return resourceKey{
gvk: r.GetObjectKind().GroupVersionKind(),
name: getResourceName(r),
namespace: 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
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pkg/validate/validate.go` around lines 63 - 97, `resourceKey` is vulnerable
to collisions because it concatenates GVK, name, and namespace into a single
string with hyphens, so distinct resources can map to the same key and mispair
old/new objects in `Validate`; replace the string-based keying in `Validate` and
`resourceKey` with a struct or other unambiguous composite key built from
`GroupVersionKind`, `getResourceName`, and `GetNamespace`, and update the
`oldByKey` map accordingly so duplicate handling cannot silently match the wrong
resource. If `fmt` is only used by `resourceKey`, remove that import after the
refactor.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Feature: supply old resources to crossplane resource validate to test transition rules

1 participant