Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions pkg/cel/environment.go
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,30 @@ func TypedEnvironment(schemas map[string]*spec.Schema) (*cel.Env, error) {
return DefaultEnvironment(WithTypedResources(schemas))
}

// CreateDeclTypeProvider creates a DeclTypeProvider from OpenAPI schemas.
// This is used for deep introspection of type structures when generating schemas.
// The provider maps CEL type names to their full DeclType definitions with all fields.
func CreateDeclTypeProvider(schemas map[string]*spec.Schema) *apiservercel.DeclTypeProvider {
if len(schemas) == 0 {
return nil
}

declTypes := make([]*apiservercel.DeclType, 0, len(schemas))
for name, schema := range schemas {
declType := openapi.SchemaDeclType(schema, false)
if declType != nil {
declType = declType.MaybeAssignTypeName(name)
declTypes = append(declTypes, declType)
}
}

if len(declTypes) == 0 {
return nil
}

return apiservercel.NewDeclTypeProvider(declTypes...)
}

// UntypedEnvironment creates a CEL environment without type declarations.
//
// This is theoretically cheaper to use as there are no Schema conversions
Expand Down
74 changes: 66 additions & 8 deletions pkg/graph/builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -546,8 +546,9 @@ func buildInstanceSpecSchema(rgSchema *v1alpha1.Schema) (*extv1.JSONSchemaProps,
return instanceSchema, nil
}

// buildStatusSchema builds the status schema for the instance resource. The
// status schema is inferred from the CEL expressions in the status field.
// buildStatusSchema builds the status schema for the instance resource.
// The status schema is inferred from the CEL expressions in the status field
// using CEL type checking.
func buildStatusSchema(
rgSchema *v1alpha1.Schema,
resources map[string]*Resource,
Expand All @@ -563,17 +564,74 @@ func buildStatusSchema(
return nil, nil, fmt.Errorf("failed to unmarshal status schema: %w", err)
}

// different from the instance spec, the status schema is inferred from the
// CEL expressions in the status field.
// Extract CEL expressions from the status field.
fieldDescriptors, err := parser.ParseSchemalessResource(unstructuredStatus)
if err != nil {
return nil, nil, fmt.Errorf("failed to extract CEL expressions from status: %w", err)
}

// For now, return a permissive schema that allows any fields.
statusSchema := &extv1.JSONSchemaProps{
Type: "object",
AdditionalProperties: &extv1.JSONSchemaPropsOrBool{Allows: true},
schemas := make(map[string]*spec.Schema)
for id, resource := range resources {
if resource.schema != nil {
schemas[id] = resource.schema
}
}

env, err := krocel.TypedEnvironment(schemas)
if err != nil {
return nil, nil, fmt.Errorf("failed to create typed CEL environment: %w", err)
}

provider := krocel.CreateDeclTypeProvider(schemas)

// Infer types for each status field expression using CEL type checking
statusTypeMap := make(map[string]*cel.Type)
for _, fieldDescriptor := range fieldDescriptors {
if len(fieldDescriptor.Expressions) == 1 {
expression := fieldDescriptor.Expressions[0]

parsedAST, issues := env.Parse(expression)
if issues != nil && issues.Err() != nil {
return nil, nil, fmt.Errorf("failed to parse status expression %q at path %q: %w",
expression, fieldDescriptor.Path, issues.Err())
}

checkedAST, issues := env.Check(parsedAST)
if issues != nil && issues.Err() != nil {
return nil, nil, fmt.Errorf("failed to type-check status expression %q at path %q: %w",
expression, fieldDescriptor.Path, issues.Err())
}

statusTypeMap[fieldDescriptor.Path] = checkedAST.OutputType()
} else {
for _, expression := range fieldDescriptor.Expressions {
parsedAST, issues := env.Parse(expression)
if issues != nil && issues.Err() != nil {
return nil, nil, fmt.Errorf("failed to parse status expression %q at path %q: %w",
expression, fieldDescriptor.Path, issues.Err())
}

checkedAST, issues := env.Check(parsedAST)
if issues != nil && issues.Err() != nil {
return nil, nil, fmt.Errorf("failed to type-check status expression %q at path %q: %w",
expression, fieldDescriptor.Path, issues.Err())
}

outputType := checkedAST.OutputType()
// multiple expressions per field - all must be strings for now.
if err := validateExpressionType(outputType, []string{"string"}, expression, "status", fieldDescriptor.Path); err != nil {
return nil, nil, err
}
}
// All expressions are strings - result type is string
statusTypeMap[fieldDescriptor.Path] = cel.StringType
Copy link

Choose a reason for hiding this comment

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

This is when we have a status like

status:
    someField: ${ expressionA }${ expressionB }

correct?

Could we find/test any case where type(expressionA)type(expressionB) is not a string and returns something valid at the CEL level?

2 side notes/nit (just thinking out loud)

I wonder if we wouldn't gain readability flexibility building directly the extva.JSONSchemaProps . Eventually, we could also declare statusTypeMap := make(map[string][]*cel.Type) so we can handle options like ``

Copy link
Member

Choose a reason for hiding this comment

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

+1 here, the CEL type of status.someField could be checked against the typed schema type for its assigneability. I think there are cases when expression results could be integers or nested objects. otherwise its a missing limitation doc I think

Copy link
Member Author

Choose a reason for hiding this comment

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

This is intentional. In status field schema generation, for "single expression fields" any type allowed:

status:
  replicas: ${deployment.status.replicas}  # int
  metadata: ${deployment.metadata}  # object

However with "multi expression fields" we do have restrictions:

status:
  allowed: "Count: ${string(a.spec.replicas)}, Ready: ${string(a.status.ready)}" #allowed
  notAllowed: "${deployment.metadata}-hellol-world-${deployment.status.replicas}"

Concatenating arbitrary types (objects, bools, ints) is complex and error prone. I'm just starting restrictive, we can relax later if there are good use cases and it's technically feasible. For now, users need explicit string() calls for multi exprssion fields.

Copy link
Member Author

Choose a reason for hiding this comment

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

Some historical context: Initially kro didn't support multi expression fields at all. We added it because users wanted simple string templating instead of complex CEL string manipulation and concatenation expressions. For example IAM policies:

status:
  policy: |
    {
      "Effect": "Allow",
      "Resource": "arn:aws:s3:::${bucket.name}/*",
      "Principal": "${serviceAccount.metadata.name}"
    }

vs:

status:
  policy: | ${json.marshal({ # json.Marshall doesn't exist yet AFAIK
      "Effect": "Allow",
      "Resource": "arn:aws:s3:::" + bucket.name + "/*",
      "Principal": serviceAccount.metadata.name
    })}

Copy link
Member

Choose a reason for hiding this comment

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

I have to admit I completely missed that we are in the else branch which only deals with concatenated expressions.

  1. A single expression, strictly typed
  2. Multiple expressions concatenated, always strings

The argument right now is that status fields only accept strings, whereas in theory they can be object types or integers or others when working with the first case. for the second case, they must stay strings (as you outlined). I subscribe to that 100% and I think we should leave this as is

Copy link

@tjamet tjamet Oct 30, 2025

Choose a reason for hiding this comment

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

The more I think about it, the less I see an instance of ${ expressionA }${ expressionB } where expressionA or expressionB are not strings and the expression makes sense.

edit:

Actually, yes, there is a case, when crafting a json as you shown for example

status:
   someField: |
      {
          "generation": ${resource.metadata.generation}
      }

Then, generation is an int and gets into a string.
How hard would it be to force a .String)() call?

}
}

// convert the CEL types to OpenAPI schema - best effort.
statusSchema, err := schema.GenerateSchemaFromCELTypes(statusTypeMap, provider)
if err != nil {
return nil, nil, fmt.Errorf("failed to generate status schema from CEL types: %w", err)
}

return statusSchema, fieldDescriptors, nil
Expand Down
253 changes: 253 additions & 0 deletions pkg/graph/schema/conversion_cel_type.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,253 @@
// Copyright 2025 The Kubernetes Authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package schema

import (
"fmt"

"github.com/google/cel-go/cel"
extv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
apiservercel "k8s.io/apiserver/pkg/cel"
"k8s.io/utils/ptr"
)

// GenerateSchemaFromCELTypes generates a JSONSchemaProps from a map of CEL types.
// The provider is used to recursively introspect struct types and extract all fields.
func GenerateSchemaFromCELTypes(typeMap map[string]*cel.Type, provider *apiservercel.DeclTypeProvider) (*extv1.JSONSchemaProps, error) {
fieldDescriptors := make([]fieldDescriptor, 0, len(typeMap))

for path, celType := range typeMap {
exprSchema, err := inferSchemaFromCELType(celType, provider)
if err != nil {
return nil, fmt.Errorf("failed to infer schema from type at %v: %w", path, err)
}
fieldDescriptors = append(fieldDescriptors, fieldDescriptor{
Path: path,
Schema: exprSchema,
})
}

return generateJSONSchemaFromFieldDescriptors(fieldDescriptors)
}

// primitiveTypeToSchema converts a CEL primitive type name to a JSONSchemaProps.
func primitiveTypeToSchema(typeName string) (*extv1.JSONSchemaProps, bool) {
switch typeName {
case "bool":
return &extv1.JSONSchemaProps{Type: "boolean"}, true
case "int", "uint":
return &extv1.JSONSchemaProps{Type: "integer"}, true
case "double":
return &extv1.JSONSchemaProps{Type: "number"}, true
case "string":
return &extv1.JSONSchemaProps{Type: "string"}, true
case "bytes":
return &extv1.JSONSchemaProps{Type: "string", Format: "byte"}, true
case "null_type":
return &extv1.JSONSchemaProps{
Description: "null type - any value allowed",
XPreserveUnknownFields: ptr.To(true),
}, true
default:
return nil, false
}
}

// inferSchemaFromCELType converts a CEL type to an OpenAPI schema.
// For struct types, it uses the provider to recursively extract all fields.
func inferSchemaFromCELType(celType *cel.Type, provider *apiservercel.DeclTypeProvider) (*extv1.JSONSchemaProps, error) {
if celType == nil {
return nil, fmt.Errorf("type is nil")
}

typeName := celType.String()

// Handle primitive types
if schema, ok := primitiveTypeToSchema(typeName); ok {
return schema, nil
}

// Handle complex types based on kind
switch celType.Kind() {
case cel.ListKind:
if provider != nil {
declType, found := provider.FindDeclType(typeName)
if found {
visited := make(map[string]bool)
return extractSchemaFromDeclTypeWithCycleDetection(declType, visited)
}
}
// Fallback: try to infer from CEL type parameters
if len(celType.Parameters()) > 0 {
elemType := celType.Parameters()[0]
elemSchema, err := inferSchemaFromCELType(elemType, provider)
if err != nil {
return nil, fmt.Errorf("failed to infer array element type: %w", err)
}
return &extv1.JSONSchemaProps{
Type: "array",
Items: &extv1.JSONSchemaPropsOrArray{
Schema: elemSchema,
},
}, nil
}
return &extv1.JSONSchemaProps{Type: "array"}, nil

case cel.MapKind:
if provider != nil {
declType, found := provider.FindDeclType(typeName)
if found {
visited := make(map[string]bool)
return extractSchemaFromDeclTypeWithCycleDetection(declType, visited)
}
}
// Fallback: try to infer from CEL type parameters
if len(celType.Parameters()) > 1 {
valueType := celType.Parameters()[1]
valueSchema, err := inferSchemaFromCELType(valueType, provider)
if err != nil {
return nil, fmt.Errorf("failed to infer map value type: %w", err)
}
return &extv1.JSONSchemaProps{
Type: "object",
AdditionalProperties: &extv1.JSONSchemaPropsOrBool{
Schema: valueSchema,
},
}, nil
}
return &extv1.JSONSchemaProps{
Type: "object",
AdditionalProperties: &extv1.JSONSchemaPropsOrBool{Allows: true},
}, nil

case cel.StructKind:
if provider != nil {
declType, found := provider.FindDeclType(typeName)
if found {
visited := make(map[string]bool)
return extractSchemaFromDeclTypeWithCycleDetection(declType, visited)
}
}
// Fallback: permissive object schema
return &extv1.JSONSchemaProps{
Type: "object",
AdditionalProperties: &extv1.JSONSchemaPropsOrBool{Allows: true},
}, nil

case cel.DynKind:
return &extv1.JSONSchemaProps{
XPreserveUnknownFields: ptr.To(true),
}, nil

default:
// Unknown type - be permissive
return &extv1.JSONSchemaProps{
Description: fmt.Sprintf("unknown CEL type: %s", typeName),
XPreserveUnknownFields: ptr.To(true),
}, nil
}
}

// extractSchemaFromDeclTypeWithCycleDetection recursively extracts all fields from a DeclType to build an OpenAPI schema.
// It tracks visited types to prevent infinite recursion on cyclic type definitions.
func extractSchemaFromDeclTypeWithCycleDetection(declType *apiservercel.DeclType, visited map[string]bool) (*extv1.JSONSchemaProps, error) {
if declType == nil {
return &extv1.JSONSchemaProps{XPreserveUnknownFields: ptr.To(true)}, nil
}

// Get type identifier for cycle detection
celType := declType.CelType()
if celType != nil {
typeName := celType.String()

if visited[typeName] {
return nil, fmt.Errorf("cyclic type reference detected: %s", typeName)
}

visited[typeName] = true
defer delete(visited, typeName)
}

// Handle different DeclType kinds
if declType.IsList() {
elemType := declType.ElemType
if elemType != nil {
elemSchema, err := extractSchemaFromDeclTypeWithCycleDetection(elemType, visited)
if err != nil {
return nil, fmt.Errorf("failed to extract array element schema: %w", err)
}
return &extv1.JSONSchemaProps{
Type: "array",
Items: &extv1.JSONSchemaPropsOrArray{
Schema: elemSchema,
},
}, nil
}
return &extv1.JSONSchemaProps{Type: "array"}, nil
}

if declType.IsMap() {
valueType := declType.ElemType
if valueType != nil {
valueSchema, err := extractSchemaFromDeclTypeWithCycleDetection(valueType, visited)
if err != nil {
return nil, fmt.Errorf("failed to extract map value schema: %w", err)
}
return &extv1.JSONSchemaProps{
Type: "object",
AdditionalProperties: &extv1.JSONSchemaPropsOrBool{
Schema: valueSchema,
},
}, nil
}
return &extv1.JSONSchemaProps{
Type: "object",
AdditionalProperties: &extv1.JSONSchemaPropsOrBool{Allows: true},
}, nil
}

if !declType.IsObject() {
celType := declType.CelType()
if celType != nil {
typeName := celType.String()
if schema, ok := primitiveTypeToSchema(typeName); ok {
return schema, nil
}
}
return &extv1.JSONSchemaProps{XPreserveUnknownFields: ptr.To(true)}, nil
}

schema := &extv1.JSONSchemaProps{
Type: "object",
Properties: make(map[string]extv1.JSONSchemaProps),
}

for _, field := range declType.Fields {
if field == nil {
continue
}

fieldSchema, err := extractSchemaFromDeclTypeWithCycleDetection(field.Type, visited)
if err != nil {
return nil, fmt.Errorf("failed to extract schema for field %s: %w", field.Name, err)
}

if fieldSchema != nil {
schema.Properties[field.Name] = *fieldSchema
}
}

return schema, nil
}
Loading