Repository: kro-run/kro
Pull request #463: Fix nil pointer panic when evaluating CEL expressions with CRDs
Author: login: rakhmanov
Created: 2025-03-27T21:54:06Z | Merged: 2025-03-30T01:08:02Z

The graph builder was attempting to access emulatedObject on resources without checking if it exists first. Since CRDs don't have emulated objects, this was causing a panic when any CEL expressions were present in the resource graph.

By adding conditional lookup on resource.emulatedObject . Avoiding null pointer dereference when working with types that dont have it: `apiextensions.k8s.io, v1, CustomResourceDefinition` in [builder.go#L286](https://github.com/kro-run/kro/blob/4a5b2a310b77fbd43f1babfaac53116d309e8980/pkg/graph/builder.go#L286)

Resolving #461 


Diff:
diff --git a/pkg/graph/builder.go b/pkg/graph/builder.go
index 9de7b0451..3b0c3b2fd 100644
--- a/pkg/graph/builder.go
+++ b/pkg/graph/builder.go
@@ -632,7 +632,9 @@ func dryRunExpression(env *cel.Env, expression string, resources map[string]*Res
 
 	context := map[string]interface{}{}
 	for resourceName, resource := range resources {
-		context[resourceName] = resource.emulatedObject.Object
+		if resource.emulatedObject != nil {
+			context[resourceName] = resource.emulatedObject.Object
+		}
 	}
 
 	output, _, err := program.Eval(context)
diff --git a/pkg/graph/builder_test.go b/pkg/graph/builder_test.go
index 198eac39a..91c4abd3f 100644
--- a/pkg/graph/builder_test.go
+++ b/pkg/graph/builder_test.go
@@ -1183,6 +1183,89 @@ func TestGraphBuilder_ExpressionParsing(t *testing.T) {
 				})
 			},
 		},
+		{
+			name: "crds not failing when cel is present in other resources",
+			resourceGraphDefinitionOpts: []generator.ResourceGraphDefinitionOption{
+				generator.WithSchema(
+					"Test", "v1alpha1",
+					map[string]interface{}{
+						"name": "string",
+					},
+					nil,
+				),
+				generator.WithResource("somecrd", map[string]interface{}{
+					"apiVersion": "apiextensions.k8s.io/v1",
+					"kind":       "CustomResourceDefinition",
+					"metadata": map[string]interface{}{
+						"name": "somecrd.ec2.services.k8s.aws",
+					},
+					"spec": map[string]interface{}{
+						"group":   "ec2.services.k8s.aws",
+						"version": "v1alpha1",
+						"names": map[string]interface{}{
+							"kind":     "SomeCRD",
+							"listKind": "SomeCRDList",
+							"singular": "SomeCRD",
+							"plural":   "SomeCRDs",
+						},
+						"scope": "Namespaced",
+					},
+				}, nil, nil),
+				generator.WithResource("vpc", map[string]interface{}{
+					"apiVersion": "ec2.services.k8s.aws/v1alpha1",
+					"kind":       "VPC",
+					"metadata": map[string]interface{}{
+						"name": "vpc",
+					},
+					"spec": map[string]interface{}{
+						"cidrBlocks": []interface{}{"10.0.0.0/16"},
+					},
+				}, []string{
+					"${vpc.status.state == 'available'}",
+					"${vpc.status.vpcID != ''}",
+				}, nil),
+				generator.WithResource("subnet1", map[string]interface{}{
+					"apiVersion": "ec2.services.k8s.aws/v1alpha1",
+					"kind":       "Subnet",
+					"metadata": map[string]interface{}{
+						"name": "subnet1",
+					},
+					"spec": map[string]interface{}{
+						"vpcID":     "${vpc.metadata.name}",
+						"cidrBlock": "10.0.1.0/24",
+					},
+				}, nil, nil),
+			},
+			validateVars: func(t *testing.T, g *Graph) {
+				somecrd := g.Resources["somecrd"]
+				assert.Empty(t, somecrd.variables)
+				assert.Empty(t, somecrd.GetReadyWhenExpressions())
+				assert.Empty(t, somecrd.GetIncludeWhenExpressions())
+
+				// Verify resource with only readyWhen
+				vpc := g.Resources["vpc"]
+				assert.Empty(t, vpc.variables)
+				assert.Equal(t, []string{
+					"vpc.status.state == 'available'",
+					"vpc.status.vpcID != ''",
+				}, vpc.GetReadyWhenExpressions())
+				assert.Empty(t, vpc.GetIncludeWhenExpressions())
+
+				// Verify resource with mixed expressions
+				subnet := g.Resources["subnet1"]
+				assert.Len(t, subnet.variables, 1)
+				// Create expected variables to match against
+				validateVariables(t, subnet.variables, []expectedVar{
+					{
+						path:                 "spec.vpcID",
+						expressions:          []string{"vpc.metadata.name"},
+						kind:                 variable.ResourceVariableKindDynamic,
+						standaloneExpression: true,
+					},
+				})
+
+			},
+		},
 	}
 
 	for _, tt := range tests {


Reviews:
user: login: n3wscott
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-27T23:44:51Z
user: login: rakhmanov
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-28T13:39:23Z
user: login: a-hilaly
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-28T18:07:54Z
user: login: a-hilaly
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-28T18:34:56Z
user: login: rakhmanov
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-28T20:31:12Z
user: login: a-hilaly
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-28T23:03:14Z
user: login: a-hilaly
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-28T23:06:39Z
user: login: a-hilaly
body: Thanks a lot @Rakhmanov! We can eventually address https://github.com/kro-run/kro/pull/463/files#r2019539364
state: APPROVED
author_association: MEMBER
submitted_at: 2025-03-30T01:07:50Z
user: login: rushmash91
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-30T01:38:20Z

Review comments:
diff_hunk: @@ -632,7 +632,9 @@ func dryRunExpression(env *cel.Env, expression string, resources map[string]*Res
 
 	context := map[string]interface{}{}
 	for resourceName, resource := range resources {
-		context[resourceName] = resource.emulatedObject.Object
+		if resource.emulatedObject != nil {
+			context[resourceName] = resource.emulatedObject.Object
path: pkg/graph/builder.go
user: login: n3wscott
body: should we return an error if the object is nil?
created_at: 2025-03-27T23:44:50Z
author_association: CONTRIBUTOR
diff_hunk: @@ -632,7 +632,9 @@ func dryRunExpression(env *cel.Env, expression string, resources map[string]*Res
 
 	context := map[string]interface{}{}
 	for resourceName, resource := range resources {
-		context[resourceName] = resource.emulatedObject.Object
+		if resource.emulatedObject != nil {
+			context[resourceName] = resource.emulatedObject.Object
path: pkg/graph/builder.go
user: login: rakhmanov
body: Its expected to be nil for the CRD type. So no, because it does not have schema that can be parsed [here](https://github.com/kro-run/kro/blob/4a5b2a310b77fbd43f1babfaac53116d309e8980/pkg/graph/builder.go#L304C35-L304C48).
created_at: 2025-03-28T13:39:23Z
author_association: CONTRIBUTOR
diff_hunk: @@ -632,7 +632,9 @@ func dryRunExpression(env *cel.Env, expression string, resources map[string]*Res
 
 	context := map[string]interface{}{}
 	for resourceName, resource := range resources {
-		context[resourceName] = resource.emulatedObject.Object
+		if resource.emulatedObject != nil {
+			context[resourceName] = resource.emulatedObject.Object
path: pkg/graph/builder.go
user: login: a-hilaly
body: Ohh good catch @Rakhmanov - i wonder why didn't we see these panics when we first tested these RGDs https://github.com/kro-run/kro/blob/main/examples/aws/ack-controller/crds/ecr-public-crd-group.yaml (i have those running in my test cluster w/ kro 0.2.2, no panic observed)
created_at: 2025-03-28T18:07:54Z
author_association: MEMBER
diff_hunk: @@ -632,7 +632,9 @@ func dryRunExpression(env *cel.Env, expression string, resources map[string]*Res
 
 	context := map[string]interface{}{}
 	for resourceName, resource := range resources {
-		context[resourceName] = resource.emulatedObject.Object
+		if resource.emulatedObject != nil {
+			context[resourceName] = resource.emulatedObject.Object
path: pkg/graph/builder.go
user: login: a-hilaly
body: cc @rushmash91 
created_at: 2025-03-28T18:34:56Z
author_association: MEMBER
diff_hunk: @@ -632,7 +632,9 @@ func dryRunExpression(env *cel.Env, expression string, resources map[string]*Res
 
 	context := map[string]interface{}{}
 	for resourceName, resource := range resources {
-		context[resourceName] = resource.emulatedObject.Object
+		if resource.emulatedObject != nil {
+			context[resourceName] = resource.emulatedObject.Object
path: pkg/graph/builder.go
user: login: rakhmanov
body: @a-hilaly Those are precisely were my starting point and comparison, I could not figure it out why they worked and mine didn't, until ran it with debugger. Since in these CRD's there are 0 usage of cel in any of the resources, they never go down the path of access emulatedObject parameter. And once at least one resource does have usage, you get the panics. I've added the test that should fail on main, and pass on this branch. 
created_at: 2025-03-28T20:31:12Z
author_association: CONTRIBUTOR
diff_hunk: @@ -632,7 +632,9 @@ func dryRunExpression(env *cel.Env, expression string, resources map[string]*Res
 
 	context := map[string]interface{}{}
 	for resourceName, resource := range resources {
-		context[resourceName] = resource.emulatedObject.Object
+		if resource.emulatedObject != nil {
+			context[resourceName] = resource.emulatedObject.Object
path: pkg/graph/builder.go
user: login: a-hilaly
body: Nice sleuthing! Indeed, it's the CEL expression usage that triggers this behavior. The graph builder dry-run component assumed that all resources would have an emulated object, which is not the case for CRDs.
created_at: 2025-03-28T23:03:14Z
author_association: MEMBER
diff_hunk: @@ -632,7 +632,9 @@ func dryRunExpression(env *cel.Env, expression string, resources map[string]*Res
 
 	context := map[string]interface{}{}
 	for resourceName, resource := range resources {
-		context[resourceName] = resource.emulatedObject.Object
+		if resource.emulatedObject != nil {
+			context[resourceName] = resource.emulatedObject.Object
path: pkg/graph/builder.go
user: login: a-hilaly
body: Now I'm wondering, what would happen if a CEL expression tries to reference a CRD? It can't be validated since CRDs don't have emulated objects. I'm initially thinking about two options
- Allow references to the CRD metadata at least, by injecting a CRD with no versions/schemas.
- Do not allow CEL expressions to reference CRDs (leaning more towards this as a short term option, we can always expand later on)
created_at: 2025-03-28T23:06:39Z
author_association: MEMBER
diff_hunk: @@ -632,7 +632,9 @@ func dryRunExpression(env *cel.Env, expression string, resources map[string]*Res
 
 	context := map[string]interface{}{}
 	for resourceName, resource := range resources {
-		context[resourceName] = resource.emulatedObject.Object
+		if resource.emulatedObject != nil {
+			context[resourceName] = resource.emulatedObject.Object
path: pkg/graph/builder.go
user: login: rushmash91
body: Dang! Nice catch!
created_at: 2025-03-30T01:38:20Z
author_association: MEMBER

PR comments:
user: login: rakhmanov
created_at: 2025-03-31T17:22:49Z
body: > Thanks a lot @Rakhmanov! We can eventually address https://github.com/kro-run/kro/pull/463/files#r2019539364

Accessing metadata makes sense, would be interesting how to make it obvious to the user the "uniqueness" of the CRD vs any other resource, but its better to have metadata values than nothing.  
author_association: CONTRIBUTOR
user: login: a-hilaly
created_at: 2025-03-31T18:23:21Z
body: @Rakhmanov would like to take a stab at that? basically for CRD we can inject "dummy" emulated CRDs that only contain metadata and basic version fields, without setting any openapisec.
author_association: MEMBER