Skip to content

Commit b6d5276

Browse files
fix: Add structural type compatibility checking for CEL validation (#813)
This patch implements structural type compatibility checking to resolve false positive type errors in CEL expression validation. The core issue is that CEL's `IsAssignable` performs nominal type checking, which rejects structurally compatible types that have different names. This becomes problematic in kro where we generate unique type names for each resource's schema (e.g `__type_pod.spec.containers` vs `__type_deployment.spec.template.speccontainers`), causing validation to fail even when the underlying structure is identical. In a nutshell, we introduces `AreTypesStructurallyCompatible`, a function that performs deep structural comparison of CEL types using proper type checking semantics. Wehn nominal type checking fails, we fall back to structural checking to determine if types are truly incompatible. This approach checks that the output type is a valid subset of the expected type, meaning it can have fewer fields but cannot have extra fields or mismatched types cases handled: - Primitive types: simple kind equality checking (string, int, bool, double...) - List types: recursive element type compatibility checking with proper handling of nested arrays - Map types: both key and value type compatibility with recursive validation - Struct types: field by field compatibility using `DeclTypeProvider` for introspection, supporting subset semantics where output can omit fields but cannot add unexpected ones - Nested structures: deep traversal through complex type hierarchies including arrays of structs and maps of structs - Cross resource references: enables copying entire structs between resources (e.g `${pod.spec.containers}` into another pod's spec) The parser now leaves `ExpectedType` nil for field descriptors, deferring type resolution to the builder. The builder implements `setExpectedTypeOnDescriptor` which walks through schema paths, constructing proper type names that include the full path (e.g `__type_schema.spec.ports.[at]idx`). This ensures that when we perform structural checking, the `DeclTypeProvider` can correctly resolve and introspect the type definitions. Future work: The current implementation works at the CEL type level, which requires converting OpenAPI schemas to CEL types and then performing structural comparison on the CEL representation. A more efficient approach would be to implement schema-level structural comparison directly on the OpenAPI `spec.Schema` types. This would avoid the conversion overhead and provide more direct access to schema metadata like required fields, default values, and validation constraintz. However, the current CEL based approach is sufficient for our needs and integrates cleanly with the existing validation infrastructure. Co-authored-by: Jakob Möller <[email protected]>
1 parent 5eb29be commit b6d5276

File tree

14 files changed

+1492
-58
lines changed

14 files changed

+1492
-58
lines changed

pkg/cel/compatibility.go

Lines changed: 270 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,270 @@
1+
// Copyright 2025 The Kubernetes Authors.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package cel
16+
17+
import (
18+
"fmt"
19+
"strings"
20+
21+
"github.com/google/cel-go/cel"
22+
apiservercel "k8s.io/apiserver/pkg/cel"
23+
)
24+
25+
const (
26+
// TypeNamePrefix is the prefix used for CEL type names when converting OpenAPI schemas.
27+
// Used to namespace custom types and avoid conflicts with built-in CEL types.
28+
// Example: "__type_schema.spec.ports" for a ports field in the schema resource.
29+
TypeNamePrefix = "__type_"
30+
)
31+
32+
// AreTypesStructurallyCompatible checks if two CEL types are structurally compatible,
33+
// ignoring type names and using duck-typing semantics.
34+
//
35+
// This performs deep structural comparison:
36+
// - For primitives: checks kind equality
37+
// - For lists: recursively checks element type compatibility
38+
// - For maps: recursively checks key and value type compatibility
39+
// - For structs: uses DeclTypeProvider to introspect fields and check all required fields exist with compatible types
40+
//
41+
// The provider is required for introspecting struct field information.
42+
// Returns true if types are compatible, false otherwise. If false, the error describes why.
43+
func AreTypesStructurallyCompatible(output, expected *cel.Type, provider *DeclTypeProvider) (bool, error) {
44+
if output == nil || expected == nil {
45+
return false, fmt.Errorf("nil type(s): output=%v, expected=%v", output, expected)
46+
}
47+
48+
// Dynamic type is compatible with anything
49+
if expected.Kind() == cel.DynKind || output.Kind() == cel.DynKind {
50+
return true, nil
51+
}
52+
53+
// Kinds must match
54+
if output.Kind() != expected.Kind() {
55+
return false, fmt.Errorf("type kind mismatch: got %q, expected %q", output.String(), expected.String())
56+
}
57+
58+
switch expected.Kind() {
59+
case cel.ListKind:
60+
return areListTypesCompatible(output, expected, provider)
61+
case cel.MapKind:
62+
return areMapTypesCompatible(output, expected, provider)
63+
case cel.StructKind:
64+
return areStructTypesCompatible(output, expected, provider)
65+
default:
66+
// For primitives (int, string, bool, etc.), kind equality is enough
67+
return true, nil
68+
}
69+
}
70+
71+
// areListTypesCompatible checks if list element types are structurally compatible.
72+
func areListTypesCompatible(output, expected *cel.Type, provider *DeclTypeProvider) (bool, error) {
73+
outputParams := output.Parameters()
74+
expectedParams := expected.Parameters()
75+
76+
// Both must have element type parameters
77+
if len(outputParams) == 0 || len(expectedParams) == 0 {
78+
if len(outputParams) != len(expectedParams) {
79+
return false, fmt.Errorf("list parameter count mismatch: got %d, expected %d", len(outputParams), len(expectedParams))
80+
}
81+
return true, nil
82+
}
83+
84+
// Recursively check element type compatibility
85+
compatible, err := AreTypesStructurallyCompatible(outputParams[0], expectedParams[0], provider)
86+
if !compatible {
87+
return false, fmt.Errorf("list element type incompatible: %w", err)
88+
}
89+
return true, nil
90+
}
91+
92+
// areMapTypesCompatible checks if map key and value types are structurally compatible.
93+
func areMapTypesCompatible(output, expected *cel.Type, provider *DeclTypeProvider) (bool, error) {
94+
outputParams := output.Parameters()
95+
expectedParams := expected.Parameters()
96+
97+
// Both must have key and value type parameters
98+
if len(outputParams) < 2 || len(expectedParams) < 2 {
99+
if len(outputParams) != len(expectedParams) {
100+
return false, fmt.Errorf("map parameter count mismatch: got %d, expected %d", len(outputParams), len(expectedParams))
101+
}
102+
return true, nil
103+
}
104+
105+
// Check key type compatibility
106+
compatible, err := AreTypesStructurallyCompatible(outputParams[0], expectedParams[0], provider)
107+
if !compatible {
108+
return false, fmt.Errorf("map key type incompatible: %w", err)
109+
}
110+
111+
// Check value type compatibility
112+
compatible, err = AreTypesStructurallyCompatible(outputParams[1], expectedParams[1], provider)
113+
if !compatible {
114+
return false, fmt.Errorf("map value type incompatible: %w", err)
115+
}
116+
return true, nil
117+
}
118+
119+
// areStructTypesCompatible checks if struct types are structurally compatible
120+
// by introspecting their fields using the DeclTypeProvider.
121+
func areStructTypesCompatible(output, expected *cel.Type, provider *DeclTypeProvider) (bool, error) {
122+
if provider == nil {
123+
// Without provider, we can't introspect fields - fall back to kind check only
124+
return true, nil
125+
}
126+
127+
// Resolve DeclTypes by walking through nested type paths
128+
expectedDecl := resolveDeclTypeFromPath(expected.String(), provider)
129+
outputDecl := resolveDeclTypeFromPath(output.String(), provider)
130+
131+
// If we can't resolve both types, we can't do structural comparison
132+
// Fall back to accepting it (permissive - could make this stricter)
133+
if expectedDecl == nil || outputDecl == nil {
134+
return true, nil
135+
}
136+
137+
// Check that output has all required fields of expected
138+
return areStructFieldsCompatible(outputDecl, expectedDecl, provider)
139+
}
140+
141+
// resolveDeclTypeFromPath resolves a DeclType by walking through a nested path.
142+
// For example, "[email protected]" would:
143+
// 1. Strip TypeNamePrefix and look up "ingressroute" in the provider
144+
// 2. Find the "spec" field
145+
// 3. Find the "routes" field
146+
// 4. Get the list element type (@idx)
147+
// 5. Find the "middlewares" field
148+
func resolveDeclTypeFromPath(typePath string, provider *DeclTypeProvider) *apiservercel.DeclType {
149+
if provider == nil || typePath == "" {
150+
return nil
151+
}
152+
153+
// Split the path into segments
154+
segments := strings.Split(typePath, ".")
155+
if len(segments) == 0 {
156+
return nil
157+
}
158+
159+
// Get the root name - keep it as-is (with or without prefix)
160+
rootName := segments[0]
161+
162+
// Look up the root type in the provider
163+
// Try first with the name as-is, then try without prefix if it has one
164+
currentDecl, found := provider.FindDeclType(rootName)
165+
if !found && strings.HasPrefix(rootName, TypeNamePrefix) {
166+
// Try without prefix for backwards compatibility
167+
shortName := strings.TrimPrefix(rootName, TypeNamePrefix)
168+
currentDecl, found = provider.FindDeclType(shortName)
169+
}
170+
if !found {
171+
return nil
172+
}
173+
174+
// Walk through remaining path segments
175+
for i := 1; i < len(segments); i++ {
176+
segment := segments[i]
177+
178+
// Handle list element type (@idx) and map value type (@elem)
179+
// These are KRO conventions used in DeclTypeProvider, not CEL built-ins
180+
if segment == "@idx" || segment == "@elem" {
181+
if currentDecl.ElemType != nil {
182+
currentDecl = currentDecl.ElemType
183+
} else {
184+
return nil
185+
}
186+
continue
187+
}
188+
189+
// Handle array index notation like "routes[0]" - strip the index
190+
if idx := strings.Index(segment, "["); idx != -1 {
191+
segment = segment[:idx]
192+
}
193+
194+
// Look up field in current struct
195+
if currentDecl.Fields == nil {
196+
return nil
197+
}
198+
199+
field, exists := currentDecl.Fields[segment]
200+
if !exists {
201+
return nil
202+
}
203+
204+
currentDecl = field.Type
205+
if currentDecl == nil {
206+
return nil
207+
}
208+
}
209+
210+
return currentDecl
211+
}
212+
213+
// areStructFieldsCompatible checks if output struct is a subset of expected struct.
214+
// The output type can have fewer fields than expected (subset semantics), but cannot have extra fields.
215+
// For each field that exists in output:
216+
// 1. The field must exist in expected
217+
// 2. The field type must be compatible
218+
func areStructFieldsCompatible(output, expected *apiservercel.DeclType, provider *DeclTypeProvider) (bool, error) {
219+
if expected == nil {
220+
return true, nil
221+
}
222+
223+
if output == nil {
224+
return false, fmt.Errorf("output type is nil")
225+
}
226+
227+
outputFields := output.Fields
228+
if outputFields == nil {
229+
// Output has no fields - this is a valid subset of any expected type
230+
return true, nil
231+
}
232+
233+
expectedFields := expected.Fields
234+
if expectedFields == nil {
235+
// Expected has no fields, but output does - incompatible
236+
if len(outputFields) > 0 {
237+
return false, fmt.Errorf("output has fields but expected type has none")
238+
}
239+
return true, nil
240+
}
241+
242+
// Check each field in output exists in expected with compatible type
243+
for fieldName, outputField := range outputFields {
244+
expectedField, exists := expectedFields[fieldName]
245+
246+
// Output has a field that expected doesn't have - not a subset
247+
if !exists {
248+
return false, fmt.Errorf("field %q exists in output but not in expected type", fieldName)
249+
}
250+
251+
// Field exists in both - check type compatibility recursively
252+
expectedFieldType := expectedField.Type
253+
outputFieldType := outputField.Type
254+
255+
if expectedFieldType == nil || outputFieldType == nil {
256+
continue
257+
}
258+
259+
// Recursively compare field types
260+
expectedCELType := expectedFieldType.CelType()
261+
outputCELType := outputFieldType.CelType()
262+
263+
compatible, err := AreTypesStructurallyCompatible(outputCELType, expectedCELType, provider)
264+
if !compatible {
265+
return false, fmt.Errorf("field %q has incompatible type: %w", fieldName, err)
266+
}
267+
}
268+
269+
return true, nil
270+
}

0 commit comments

Comments
 (0)