Repository: kubernetes-sigs/kro

Issue:
Nil pointer dereference on non-specified list parameter
### Description

**Observed Behavior**:
Kro did crash on a nil pointer dereference.

**Expected Behavior**:
Kro wouldn't crash (as it potentially affects other instances which causes no segfaults) and the instance wouldn't reach the Ready state.

**Reproduction Steps** (Please include `ResourceGraphDefinition` and `Instances` files):

- ResourceGraphDefinition
```yaml
apiVersion: kro.run/v1alpha1
kind: ResourceGraphDefinition

metadata:
  name: null-ref-repro-v1alpha1

spec:
  schema:
    apiVersion: v1alpha1
    kind: NullRefRepro

    types:
      ImagePullSecret:
        name: string | required=true

    spec:
      imagePullSecrets: "[]ImagePullSecret"

  resources:
    - id: deployment
      template:
        apiVersion: apps/v1
        kind: Deployment
        metadata:
          name: backend
        spec:
          selector:
            matchLabels:
              app.kubernetes.io/component: nginx
              app.kubernetes.io/instance: nginx-abcdef

          template:
            metadata:
              name: nginx
              labels:
                app.kubernetes.io/component: nginx
                app.kubernetes.io/instance: nginx-abcdef
            spec:
              containers:
                - name: nginx
                  image: nginx

              # this one, if no values are provided, seems to cause the nil ref
              imagePullSecrets: ${ schema.spec.imagePullSecrets }
```

- Instance
```yaml
apiVersion: kro.run/v1alpha1
kind: NullRefRepro
metadata:
  name: make-it-crash
  namespace: default
# as no imagePullSecrets are provided, and the parameter has no default value, it causes the crash
```

**Versions**:
- kro version: `registry.k8s.io/kro/kro:v0.6.3`
- Kubernetes Version (`kubectl version`):
  ```
  Client Version: v1.34.1
  Kustomize Version: v5.7.1
  Server Version: v1.34.1-eks-3025e55
  ```

**Error Logs** (if applicable)**:
```
2025-11-24T16:16:34.915Z	DEBUG	dynamic-controller	Finished syncing object	{"gvr": "kro.run/v1alpha1/nullrefreproes", "key": {"name":"make-it-crash","namespace":"default"}, "duration": "7.46691ms"}
E1124 16:16:34.915641       1 panic.go:262] "Observed a panic" panic="runtime error: invalid memory address or nil pointer dereference" panicGoValue="\"invalid memory address or nil pointer dereference\"" stacktrace=<
	goroutine 78 [running]:
	k8s.io/apimachinery/pkg/util/runtime.logPanic({0x1ec7738, 0xc000bc1e30}, {0x18d6020, 0x2ba6960})
		k8s.io/apimachinery@v0.34.1/pkg/util/runtime/runtime.go:132 +0xbc
	k8s.io/apimachinery/pkg/util/runtime.handleCrash({0x1ec7770, 0xc00057b680}, {0x18d6020, 0x2ba6960}, {0x0, 0x0, 0xc000291478?})
		k8s.io/apimachinery@v0.34.1/pkg/util/runtime/runtime.go:107 +0x116
	k8s.io/apimachinery/pkg/util/runtime.HandleCrashWithContext({0x1ec7770, 0xc00057b680}, {0x0, 0x0, 0x0})
		k8s.io/apimachinery@v0.34.1/pkg/util/runtime/runtime.go:78 +0x5a
	panic({0x18d6020?, 0x2ba6960?})
		runtime/panic.go:783 +0x132
	github.com/kubernetes-sigs/kro/pkg/controller/instance.(*instanceGraphReconciler).getResolvedStatus(0xc0002918f8)
		github.com/kubernetes-sigs/kro/pkg/controller/instance/controller_status.go:174 +0xa8
	github.com/kubernetes-sigs/kro/pkg/controller/instance.(*instanceGraphReconciler).prepareStatus(0xc0002918f8)
		github.com/kubernetes-sigs/kro/pkg/controller/instance/controller_status.go:142 +0x27
	github.com/kubernetes-sigs/kro/pkg/controller/instance.(*Controller).Reconcile.func1()
		github.com/kubernetes-sigs/kro/pkg/controller/instance/controller.go:148 +0x145
	github.com/kubernetes-sigs/kro/pkg/controller/instance.(*Controller).Reconcile(0xc000810d80, {0x1ec7770, 0xc00057b680}, {{{0xc000a68808?, 0x4816ce?}, {0xc000a68840?, 0x4e35b3?}}})
		github.com/kubernetes-sigs/kro/pkg/controller/instance/controller.go:157 +0x694
	github.com/kubernetes-sigs/kro/pkg/dynamiccontroller.(*DynamicController).syncFunc(0xc000016540, {0x1ec7770, 0xc00057b680}, {{{0xc000a68808, 0x7}, {0xc000a68840, 0xd}}, {{0xc0006f4bb8, 0x7}, {0xc0006f4bb0, ...}, ...}}, ...)
		github.com/kubernetes-sigs/kro/pkg/dynamiccontroller/dynamic_controller.go:326 +0x2c6
	github.com/kubernetes-sigs/kro/pkg/dynamiccontroller.(*DynamicController).processNextWorkItem(0xc000016540, {0x1ec7770, 0xc00057b680})
		github.com/kubernetes-sigs/kro/pkg/dynamiccontroller/dynamic_controller.go:272 +0x293
	github.com/kubernetes-sigs/kro/pkg/dynamiccontroller.(*DynamicController).worker(...)
		github.com/kubernetes-sigs/kro/pkg/dynamiccontroller/dynamic_controller.go:250
	k8s.io/apimachinery/pkg/util/wait.BackoffUntilWithContext.func1({0x1ec7770?, 0xc00057b680?}, 0xc000467d70?)
		k8s.io/apimachinery@v0.34.1/pkg/util/wait/backoff.go:255 +0x51
	k8s.io/apimachinery/pkg/util/wait.BackoffUntilWithContext({0x1ec7770, 0xc00057b680}, 0xc0005fb510, {0x1ea9c80, 0xc000467d70}, 0x1)
		k8s.io/apimachinery@v0.34.1/pkg/util/wait/backoff.go:256 +0xe5
	k8s.io/apimachinery/pkg/util/wait.JitterUntilWithContext({0x1ec7770, 0xc00057b680}, 0xc0005fb510, 0x3b9aca00, 0x0, 0x1)
		k8s.io/apimachinery@v0.34.1/pkg/util/wait/backoff.go:223 +0x8f
	k8s.io/apimachinery/pkg/util/wait.UntilWithContext(...)
		k8s.io/apimachinery@v0.34.1/pkg/util/wait/backoff.go:172
	created by github.com/kubernetes-sigs/kro/pkg/dynamiccontroller.(*DynamicController).Start in goroutine 76
		github.com/kubernetes-sigs/kro/pkg/dynamiccontroller/dynamic_controller.go:242 +0x15d
 >
panic: runtime error: invalid memory address or nil pointer dereference [recovered, repanicked]
[signal SIGSEGV: segmentation violation code=0x1 addr=0x18 pc=0x16df1c8]

goroutine 78 [running]:
k8s.io/apimachinery/pkg/util/runtime.handleCrash({0x1ec7770, 0xc00057b680}, {0x18d6020, 0x2ba6960}, {0x0, 0x0, 0xc000291478?})
	k8s.io/apimachinery@v0.34.1/pkg/util/runtime/runtime.go:114 +0x1a9
k8s.io/apimachinery/pkg/util/runtime.HandleCrashWithContext({0x1ec7770, 0xc00057b680}, {0x0, 0x0, 0x0})
	k8s.io/apimachinery@v0.34.1/pkg/util/runtime/runtime.go:78 +0x5a
panic({0x18d6020?, 0x2ba6960?})
	runtime/panic.go:783 +0x132
github.com/kubernetes-sigs/kro/pkg/controller/instance.(*instanceGraphReconciler).getResolvedStatus(0xc0002918f8)
	github.com/kubernetes-sigs/kro/pkg/controller/instance/controller_status.go:174 +0xa8
github.com/kubernetes-sigs/kro/pkg/controller/instance.(*instanceGraphReconciler).prepareStatus(0xc0002918f8)
	github.com/kubernetes-sigs/kro/pkg/controller/instance/controller_status.go:142 +0x27
github.com/kubernetes-sigs/kro/pkg/controller/instance.(*Controller).Reconcile.func1()
	github.com/kubernetes-sigs/kro/pkg/controller/instance/controller.go:148 +0x145
github.com/kubernetes-sigs/kro/pkg/controller/instance.(*Controller).Reconcile(0xc000810d80, {0x1ec7770, 0xc00057b680}, {{{0xc000a68808?, 0x4816ce?}, {0xc000a68840?, 0x4e35b3?}}})
	github.com/kubernetes-sigs/kro/pkg/controller/instance/controller.go:157 +0x694
github.com/kubernetes-sigs/kro/pkg/dynamiccontroller.(*DynamicController).syncFunc(0xc000016540, {0x1ec7770, 0xc00057b680}, {{{0xc000a68808, 0x7}, {0xc000a68840, 0xd}}, {{0xc0006f4bb8, 0x7}, {0xc0006f4bb0, ...}, ...}}, ...)
	github.com/kubernetes-sigs/kro/pkg/dynamiccontroller/dynamic_controller.go:326 +0x2c6
github.com/kubernetes-sigs/kro/pkg/dynamiccontroller.(*DynamicController).processNextWorkItem(0xc000016540, {0x1ec7770, 0xc00057b680})
	github.com/kubernetes-sigs/kro/pkg/dynamiccontroller/dynamic_controller.go:272 +0x293
github.com/kubernetes-sigs/kro/pkg/dynamiccontroller.(*DynamicController).worker(...)
	github.com/kubernetes-sigs/kro/pkg/dynamiccontroller/dynamic_controller.go:250
k8s.io/apimachinery/pkg/util/wait.BackoffUntilWithContext.func1({0x1ec7770?, 0xc00057b680?}, 0xc000467d70?)
	k8s.io/apimachinery@v0.34.1/pkg/util/wait/backoff.go:255 +0x51
k8s.io/apimachinery/pkg/util/wait.BackoffUntilWithContext({0x1ec7770, 0xc00057b680}, 0xc0005fb510, {0x1ea9c80, 0xc000467d70}, 0x1)
	k8s.io/apimachinery@v0.34.1/pkg/util/wait/backoff.go:256 +0xe5
k8s.io/apimachinery/pkg/util/wait.JitterUntilWithContext({0x1ec7770, 0xc00057b680}, 0xc0005fb510, 0x3b9aca00, 0x0, 0x1)
	k8s.io/apimachinery@v0.34.1/pkg/util/wait/backoff.go:223 +0x8f
k8s.io/apimachinery/pkg/util/wait.UntilWithContext(...)
	k8s.io/apimachinery@v0.34.1/pkg/util/wait/backoff.go:172
```

* Please vote on this issue by adding a 👍 [reaction](https://blog.github.com/2016-03-10-add-reactions-to-pull-requests-issues-and-comments/) to the original issue to help the community and maintainers prioritize this request
* Please do not leave "+1" or "me too" comments, they generate extra noise for issue followers and do not help prioritize the request
* If you are interested in working on this issue or have submitted a pull request, please leave a comment


### Which option describes the most your issue?

Instance (Create, Update, Deletion)

Hints from issue comments:
@abidon: Might be related to #852 but not sure if the implementation details handles list differently.@jakobmoellerdev: Same root cause, currently when our runtime graph is not buildable the status builder panics. Thanks for reporting!@jakobmoellerdev: @a-hilaly I think this issue is urgent critical and we may want to think of cutting 0.6.4 for this. We will get more and more reports. Alternative is to push out 0.7.0@a-hilaly: @jakobmoellerdev Yeah let's cut a release ASAP - have we identified the root cause for this yet?@jakobmoellerdev: This is all same root cause with runtime being nil and the defer status updater not being able to deal with it

Solution patch:
diff --git a/pkg/apis/condition_set.go b/pkg/apis/condition_set.go
index f1648c4..ef79f58 100644
--- a/pkg/apis/condition_set.go
+++ b/pkg/apis/condition_set.go
@@ -23,6 +23,7 @@ import (
 	"sort"
 
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/runtime"
 	"k8s.io/utils/ptr"
 
 	"github.com/kubernetes-sigs/kro/api/v1alpha1"
@@ -62,6 +63,17 @@ func (c ConditionSet) List() []v1alpha1.Condition {
 	return c.object.GetConditions()
 }
 
+func (c ConditionSet) AsUnstructured() []any {
+	origin := c.List()
+	data := make([]any, 0, len(origin))
+	for _, condition := range origin {
+		if raw, err := runtime.DefaultUnstructuredConverter.ToUnstructured(&condition); err == nil {
+			data = append(data, raw)
+		}
+	}
+	return data
+}
+
 // Get finds and returns the Condition that matches the ConditionType
 // previously set on Conditions.
 func (c ConditionSet) Get(t string) *v1alpha1.Condition {
diff --git a/pkg/controller/instance/controller_status.go b/pkg/controller/instance/controller_status.go
index 728acee..a556612 100644
--- a/pkg/controller/instance/controller_status.go
+++ b/pkg/controller/instance/controller_status.go
@@ -139,10 +139,10 @@ func (m *ConditionsMarker) ResourcesInProgress(format string, a ...any) {
 
 // prepareStatus creates the status object for the instance based on current state.
 func (igr *instanceGraphReconciler) prepareStatus() map[string]interface{} {
-	status := igr.getResolvedStatus()
+	status := igr.getStatusWithEmptyConditions()
 
 	// Set status.state based on readiness
-	instance := igr.runtime.GetInstance()
+	instance := igr.instance
 	conditionSet := instanceConditionTypes.For(wrapInstance(instance))
 	if conditionSet.IsRootReady() {
 		status["state"] = InstanceStateActive
@@ -151,41 +151,25 @@ func (igr *instanceGraphReconciler) prepareStatus() map[string]interface{} {
 	}
 
 	// Get conditions from the instance (set by condition markers during reconciliation)
-	if conditions := conditionSet.List(); len(conditions) > 0 {
-		// Marshal conditions to JSON and then unmarshal to []interface{} to get map[string]interface{} representation
-		conditionsJSON, err := json.Marshal(conditions)
-		if err == nil {
-			var conditionsInterface []interface{}
-			if err := json.Unmarshal(conditionsJSON, &conditionsInterface); err == nil {
-				status["conditions"] = conditionsInterface
-			}
-		}
-	}
+	_ = unstructured.SetNestedSlice(status, conditionSet.AsUnstructured(), "conditions")
 
 	return status
 }
 
-// getResolvedStatus retrieves the current status while preserving non-condition fields.
-func (igr *instanceGraphReconciler) getResolvedStatus() map[string]interface{} {
-	status := map[string]interface{}{
-		"conditions": []interface{}{},
+// getStatusWithEmptyConditions retrieves the current status while preserving non-condition fields.
+func (igr *instanceGraphReconciler) getStatusWithEmptyConditions() map[string]any {
+	base, ok, _ := unstructured.NestedMap(igr.instance.Object, "status")
+	if !ok {
+		base = map[string]interface{}{}
 	}
+	_ = unstructured.SetNestedSlice(base, []interface{}{}, "conditions") // Reset conditions in base
 
-	if existingStatus, ok := igr.runtime.GetInstance().Object["status"].(map[string]interface{}); ok {
-		// Copy existing status but reset conditions
-		for k, v := range existingStatus {
-			if k != "conditions" {
-				status[k] = v
-			}
-		}
-	}
-
-	return status
+	return base
 }
 
 // patchInstanceStatus updates the status subresource of the instance.
 func (igr *instanceGraphReconciler) patchInstanceStatus(ctx context.Context, status map[string]interface{}) error {
-	instance := igr.runtime.GetInstance().DeepCopy()
+	instance := igr.instance.DeepCopy()
 	instance.Object["status"] = status
 
 	// We are using retry.RetryOnConflict to handle conflicts.



Test patch:
diff --git a/test/integration/suites/core/instance_conditions_test.go b/test/integration/suites/core/instance_conditions_test.go
index 63f0b60..60eb8c2 100644
--- a/test/integration/suites/core/instance_conditions_test.go
+++ b/test/integration/suites/core/instance_conditions_test.go
@@ -271,4 +271,47 @@ var _ = Describe("Instance Conditions", func() {
 			}
 		}, 20*time.Second, time.Second).WithContext(ctx).Should(Succeed())
 	})
+
+	It("should surface a failure condition when a resource is invalid", func(ctx SpecContext) {
+		// RGD with a broken resource: missing 'metadata.name' in a ConfigMap
+		rgd := generator.NewResourceGraphDefinition("test-broken-resource",
+			generator.WithSchema(
+				"TestBrokenResource", "v1alpha1",
+				map[string]interface{}{
+					"value": "string",
+				},
+				nil,
+			),
+			generator.WithResource("configmap", map[string]interface{}{
+				"apiVersion": "v1",
+				"kind":       "ConfigMap",
+				"metadata": map[string]interface{}{
+					"name": "${schema.metadata.name}",
+				},
+				"data": map[string]interface{}{
+					"foo": "${schema.spec.value}",
+				},
+			}, nil, []string{
+				"${schema.spec.convex.enabled}", // Intentional error: 'convex' field does not exist
+			}),
+		)
+
+		Expect(env.Client.Create(ctx, rgd)).To(Succeed())
+
+		// Wait for RGD to be Inactive due to the broken
+		Eventually(func(g Gomega, ctx SpecContext) {
+			err := env.Client.Get(ctx, types.NamespacedName{Name: rgd.Name}, rgd)
+			g.Expect(err).ToNot(HaveOccurred())
+			g.Expect(rgd.Status.State).To(Equal(krov1alpha1.ResourceGraphDefinitionStateInactive))
+			var ready *krov1alpha1.Condition
+			for _, c := range rgd.Status.Conditions {
+				if c.Type == ctrlinstance.Ready {
+					ready = &c
+					break
+				}
+			}
+			g.Expect(ready).ToNot(BeNil())
+			g.Expect(ready.Status).To(Equal(metav1.ConditionFalse))
+		}).WithContext(ctx).WithTimeout(20 * time.Second).WithPolling(time.Second).Should(Succeed())
+	})
 })