MutatingAdmissionPolicy: a JSON patch value holding an Object initializer inside a list panics the apiserver
What happened?
A MutatingAdmissionPolicy JSON patch whose value is a list containing an Object initializer panics during patch evaluation:
panic: reflect.Set: value of type *structpb.Struct is not assignable to type *structpb.Value
The panic is not recovered inside the patch code, so it propagates out of admission evaluation.
The cause is a type contract violation in (*ObjectVal).ConvertToNative, staging/src/k8s.io/apiserver/pkg/cel/mutation/dynamic/objects.go:
if typeDesc == reflect.TypeOf(&structpb.Value{}) {
return structpb.NewStruct(result)
}
Asked for a *structpb.Value, it returns a *structpb.Struct.
For a bare object this is harmless in practice, because the only caller, evaluatePatchExpression in pkg/admission/plugin/policy/mutating/patch/json_patch.go, marshals the result to JSON immediately and both types encode identically. So the wrong type is never observed.
Inside a list it is not harmless. cel-go's baseList.ConvertToNative takes the JSONValueType branch, recurses as []*structpb.Value, and then assigns each converted element by reflection:
nativeList.Index(i).Set(reflect.ValueOf(nativeElemVal))
reflect enforces the element type there, and the mismatch becomes a panic.
What did you expect to happen?
The patch to apply, producing a list of objects at the given path. There is nothing invalid about the expression, and the equivalent patch with a bare Object initializer works.
How can we reproduce it?
Two cases added to the existing TestJSONPatch table in pkg/admission/plugin/policy/mutating/patch/json_patch_test.go. The first panics, the second passes.
{
name: "jsonPatch value is a list holding an Object initializer",
expression: `[
JSONPatch{
op: "add",
path: "/spec/template/spec/tolerations",
value: [
Object.spec.template.spec.tolerations{key: "example", operator: "Exists", effect: "NoSchedule"}
]
},
]`,
gvr: deploymentGVR,
object: &appsv1.Deployment{Spec: appsv1.DeploymentSpec{Replicas: ptr.To[int32](1)}},
expectedResult: &appsv1.Deployment{Spec: appsv1.DeploymentSpec{
Replicas: ptr.To[int32](1),
Template: corev1.PodTemplateSpec{Spec: corev1.PodSpec{Tolerations: []corev1.Toleration{{
Key: "example",
Operator: corev1.TolerationOpExists,
Effect: corev1.TaintEffectNoSchedule,
}}}},
}},
},
{
name: "jsonPatch value is a bare Object initializer",
expression: `[
JSONPatch{
op: "add",
path: "/spec/template/spec/securityContext",
value: Object.spec.template.spec.securityContext{runAsNonRoot: true}
},
]`,
gvr: deploymentGVR,
object: &appsv1.Deployment{Spec: appsv1.DeploymentSpec{Replicas: ptr.To[int32](1)}},
expectedResult: &appsv1.Deployment{Spec: appsv1.DeploymentSpec{
Replicas: ptr.To[int32](1),
Template: corev1.PodTemplateSpec{Spec: corev1.PodSpec{
SecurityContext: &corev1.PodSecurityContext{RunAsNonRoot: ptr.To(true)},
}},
}},
},
Result:
=== RUN TestJSONPatch/jsonPatch_value_is_a_list_holding_an_Object_initializer
panic: reflect.Set: value of type *structpb.Struct is not assignable to type *structpb.Value
FAIL k8s.io/apiserver/pkg/admission/plugin/policy/mutating/patch
=== RUN TestJSONPatch/jsonPatch_value_is_a_bare_Object_initializer
--- PASS: TestJSONPatch/jsonPatch_value_is_a_bare_Object_initializer
ok k8s.io/apiserver/pkg/admission/plugin/policy/mutating/patch
Because the panic aborts the test binary, the two cases have to be run separately to see the control pass.
Anything else we need to know?
structpb.NewValue(result) in place of structpb.NewStruct(result) returns the requested type and is the obvious candidate, but the conversion contract is sig-api-machinery's call, so this is an issue and not a PR. Happy to send the fix and the two test cases if that is welcome.
This reproduces against k8s.io/apiserver v0.36.4, which resolves cel-go v0.26.0. The same defect reaches Kyverno, whose MutatingPolicy JSON patch code is a fork of json_patch.go carrying the identical line, and there it reproduces with cel-go v0.31.0. So it is not specific to one cel-go release. Kyverno tracks it as kyverno/kyverno#17611, reported by a user hitting it on a live cluster, where the unrecovered panic took down their reports controller and their mutating webhook.
Kubernetes version
k8s.io/apiserver v0.36.4
Cloud provider
Not applicable, reproduced from the apiserver unit tests.
/sig api-machinery /area admission-control
Source: kubernetes/kubernetes