From e5492249bd2069c25a7541d921cb6e64f096153d Mon Sep 17 00:00:00 2001 From: ecordell Date: Fri, 13 Mar 2026 09:58:57 -0400 Subject: [PATCH] feat: add cel support to specs and fields --- pkg/config/field.go | 10 +++ pkg/config/field_test.go | 64 +++++++++++++++++++ pkg/config/resource.go | 4 ++ pkg/generate/ack/apis.go | 6 ++ pkg/model/attr.go | 12 ++-- pkg/model/crd.go | 10 +++ pkg/model/field.go | 9 +++ pkg/model/model.go | 18 ++++++ pkg/model/model_apigwv2_test.go | 29 +++++++++ pkg/model/model_route53_test.go | 36 +++++++++++ .../0000-00-00/generator-with-cel-rules.yaml | 20 ++++++ .../apis/route53/0000-00-00/generator.yaml | 8 +++ templates/apis/crd.go.tpl | 7 ++ templates/apis/type_def.go.tpl | 3 + 14 files changed, 231 insertions(+), 5 deletions(-) create mode 100644 pkg/config/field_test.go create mode 100644 pkg/testdata/models/apis/apigatewayv2/0000-00-00/generator-with-cel-rules.yaml diff --git a/pkg/config/field.go b/pkg/config/field.go index 48e3b259..48886f7a 100644 --- a/pkg/config/field.go +++ b/pkg/config/field.go @@ -384,6 +384,13 @@ type ReferencesConfig struct { Path string `json:"path"` } +// CELRule represents a single CEL (Common Expression Language) validation rule +// to be emitted as a +kubebuilder:validation:XValidation marker. +type CELRule struct { + Rule string `json:"rule"` + Message *string `json:"message,omitempty"` +} + // FieldConfig contains instructions to the code generator about how // to interpret the value of an Attribute and how to map it to a CRD's Spec or // Status field @@ -492,6 +499,9 @@ type FieldConfig struct { // // (See https://github.com/aws-controllers-k8s/pkg/blob/main/names/names.go) GoTag *string `json:"go_tag,omitempty"` + // CustomCELRules contains CEL validation rules emitted as + // +kubebuilder:validation:XValidation markers on this field. + CustomCELRules []CELRule `json:"custom_cel_rules,omitempty"` } // GetFieldConfigs returns all FieldConfigs for a given resource as a map. diff --git a/pkg/config/field_test.go b/pkg/config/field_test.go new file mode 100644 index 00000000..25830a05 --- /dev/null +++ b/pkg/config/field_test.go @@ -0,0 +1,64 @@ +// Copyright Amazon.com Inc. or its affiliates. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"). You may +// not use this file except in compliance with the License. A copy of the +// License is located at +// +// http://aws.amazon.com/apache2.0/ +// +// or in the "license" file accompanying this file. This file 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 config + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "sigs.k8s.io/yaml" +) + +func TestCELRule_Parsing(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + + yamlStr := ` +resources: + MyResource: + custom_cel_rules: + - rule: "has(self.foo)" + message: "foo is required" + - rule: "self.bar > 0" + fields: + MyField: + custom_cel_rules: + - rule: "self.matches('^[a-z]+')" + message: "must be lowercase" +` + cfg, err := New("", Config{}) + require.Nil(err) + err = yaml.UnmarshalStrict([]byte(yamlStr), &cfg) + require.Nil(err) + + resConfig, ok := cfg.Resources["MyResource"] + require.True(ok) + + // Resource-level rules + require.Len(resConfig.CustomCELRules, 2) + assert.Equal("has(self.foo)", resConfig.CustomCELRules[0].Rule) + require.NotNil(resConfig.CustomCELRules[0].Message) + assert.Equal("foo is required", *resConfig.CustomCELRules[0].Message) + assert.Equal("self.bar > 0", resConfig.CustomCELRules[1].Rule) + assert.Nil(resConfig.CustomCELRules[1].Message) // no message key + + // Field-level rules + fieldConfig, ok := resConfig.Fields["MyField"] + require.True(ok) + require.Len(fieldConfig.CustomCELRules, 1) + assert.Equal("self.matches('^[a-z]+')", fieldConfig.CustomCELRules[0].Rule) + require.NotNil(fieldConfig.CustomCELRules[0].Message) + assert.Equal("must be lowercase", *fieldConfig.CustomCELRules[0].Message) +} diff --git a/pkg/config/resource.go b/pkg/config/resource.go index 4e63fdbf..a3ad0b2d 100644 --- a/pkg/config/resource.go +++ b/pkg/config/resource.go @@ -122,6 +122,10 @@ type ResourceConfig struct { // SDK implementation details that are auto-filled by the SDK middleware // when nil and should not be exposed in the CRD. IgnoreIdempotencyToken bool `json:"ignore_idempotency_token,omitempty"` + // CustomCELRules contains CEL validation rules emitted as + // +kubebuilder:validation:XValidation markers on the Spec struct, + // enabling cross-field validation. + CustomCELRules []CELRule `json:"custom_cel_rules,omitempty"` } // TagConfig instructs the code generator on how to generate functions that diff --git a/pkg/generate/ack/apis.go b/pkg/generate/ack/apis.go index e471a95a..b4dc71cc 100644 --- a/pkg/generate/ack/apis.go +++ b/pkg/generate/ack/apis.go @@ -40,6 +40,12 @@ var ( apisCopyPaths = []string{} apisFuncMap = ttpl.FuncMap{ "Join": strings.Join, + "Deref": func(s *string) string { + if s == nil { + return "" + } + return *s + }, } ) diff --git a/pkg/model/attr.go b/pkg/model/attr.go index 701322f7..5ab555c2 100644 --- a/pkg/model/attr.go +++ b/pkg/model/attr.go @@ -17,15 +17,17 @@ import ( "fmt" awssdkmodel "github.com/aws-controllers-k8s/code-generator/pkg/api" + ackgenconfig "github.com/aws-controllers-k8s/code-generator/pkg/config" "github.com/aws-controllers-k8s/pkg/names" ) type Attr struct { - Names names.Names - GoType string - Shape *awssdkmodel.Shape - GoTag string - IsImmutable bool + Names names.Names + GoType string + Shape *awssdkmodel.Shape + GoTag string + IsImmutable bool + CustomCELRules []ackgenconfig.CELRule } func NewAttr( diff --git a/pkg/model/crd.go b/pkg/model/crd.go index f523b933..f07018fa 100644 --- a/pkg/model/crd.go +++ b/pkg/model/crd.go @@ -422,6 +422,16 @@ func (r *CRD) IsARNPrimaryKey() bool { return resGenConfig.IsARNPrimaryKey } +// CustomCELRules returns the custom CEL validation rules configured for this +// resource's Spec struct, or nil if none are configured. +func (r *CRD) CustomCELRules() []ackgenconfig.CELRule { + resGenConfig := r.cfg.GetResourceConfig(r.Names.Original) + if resGenConfig == nil { + return nil + } + return resGenConfig.CustomCELRules +} + // GetPrimaryKeyField returns the field designated as the primary key, nil if // none are specified or an error if multiple are designated. func (r *CRD) GetPrimaryKeyField() (*Field, error) { diff --git a/pkg/model/field.go b/pkg/model/field.go index bd9879f2..c613b12b 100644 --- a/pkg/model/field.go +++ b/pkg/model/field.go @@ -209,6 +209,15 @@ func (f *Field) IsImmutable() bool { return false } +// CustomCELRules returns the custom CEL validation rules configured for +// this field, or nil if none are configured. +func (f *Field) CustomCELRules() []ackgenconfig.CELRule { + if f.FieldConfig != nil { + return f.FieldConfig.CustomCELRules + } + return nil +} + // GetSetterConfig returns the SetFieldConfig object associated with this field // and a supplied operation type, or nil if none exists. func (f *Field) GetSetterConfig(opType OpType) *ackgenconfig.SetFieldConfig { diff --git a/pkg/model/model.go b/pkg/model/model.go index ba90bc0c..b3a77655 100644 --- a/pkg/model/model.go +++ b/pkg/model/model.go @@ -761,6 +761,11 @@ func (m *Model) processNestedFieldTypeDefs( return fmt.Errorf("resource %q, field %q: %w", crd.Names.Original, fieldPath, err) } } + if len(field.CustomCELRules()) > 0 { + if err := setTypeDefAttributeCELRules(crd, fieldPath, field.CustomCELRules(), tdefs); err != nil { + return fmt.Errorf("resource %q, field %q: %w", crd.Names.Original, fieldPath, err) + } + } } } return nil @@ -909,6 +914,19 @@ func setTypeDefAttributeImmutable(crd *CRD, fieldPath string, tdefs []*TypeDef) return nil } +// setTypeDefAttributeCELRules sets the CustomCELRules on the Attr corresponding +// to the nested field at fieldPath. +func setTypeDefAttributeCELRules(crd *CRD, fieldPath string, rules []ackgenconfig.CELRule, tdefs []*TypeDef) error { + _, fieldAttr, err := getAttributeFromPath(crd, fieldPath, tdefs) + if err != nil { + return err + } + if fieldAttr != nil { + fieldAttr.CustomCELRules = rules + } + return nil +} + // updateTypeDefAttributeWithReference adds a new AWSResourceReference attribute // for the corresponding attribute represented by fieldPath of nested field func updateTypeDefAttributeWithReference(crd *CRD, fieldPath string, tdefs []*TypeDef) error { diff --git a/pkg/model/model_apigwv2_test.go b/pkg/model/model_apigwv2_test.go index 58831b11..637b02b2 100644 --- a/pkg/model/model_apigwv2_test.go +++ b/pkg/model/model_apigwv2_test.go @@ -251,6 +251,35 @@ func TestAPIGatewayV2_WithReference(t *testing.T) { assert.Equal(2, len(referencedServiceNames)) } +func TestAPIGatewayV2_CustomCELRules_NestedField(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + + g := testutil.NewModelForServiceWithOptions(t, "apigatewayv2", &testutil.TestingModelOptions{ + GeneratorConfigFile: "generator-with-cel-rules.yaml", + }) + + tds, err := g.GetTypeDefs() + require.Nil(err) + require.NotNil(tds) + + var tdef *model.TypeDef + for _, td := range tds { + if td != nil && strings.EqualFold(td.Names.Original, "jwtConfiguration") { + tdef = td + break + } + } + require.NotNil(tdef, "JWTConfiguration TypeDef must exist — check SDK shape name casing if nil") + + issuerAttr, ok := tdef.Attrs["Issuer"] + require.True(ok, "Issuer attr must exist in JWTConfiguration TypeDef") + require.Len(issuerAttr.CustomCELRules, 1) + assert.Equal("self.startsWith('https://')", issuerAttr.CustomCELRules[0].Rule) + require.NotNil(issuerAttr.CustomCELRules[0].Message) + assert.Equal("Issuer must be an HTTPS URL", *issuerAttr.CustomCELRules[0].Message) +} + func TestAPIGatewayV2_WithNestedReference(t *testing.T) { _ = assert.New(t) require := require.New(t) diff --git a/pkg/model/model_route53_test.go b/pkg/model/model_route53_test.go index 56133d6b..69f49e8d 100644 --- a/pkg/model/model_route53_test.go +++ b/pkg/model/model_route53_test.go @@ -83,4 +83,40 @@ func TestRoute53_RecordSet(t *testing.T) { "SubmittedAt", } assert.Equal(expStatusFieldCamel, attrCamelNames(statusFields)) + + // A resource without custom_cel_rules configured returns nil + assert.Nil(crd.CustomCELRules()) +} + +func TestRoute53_CELRules(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + + m := testutil.NewModelForService(t, "route53") + + crds, err := m.GetCRDs() + require.Nil(err) + + // Test resource-level CEL rules on HostedZone + crd := getCRDByName("HostedZone", crds) + require.NotNil(crd) + + require.Len(crd.CustomCELRules(), 2) + assert.Equal("!has(self.hostedZoneConfig) || !self.hostedZoneConfig.privateZone || has(self.vpc)", crd.CustomCELRules()[0].Rule) + assert.NotNil(crd.CustomCELRules()[0].Message) + assert.Equal("spec.vpc is required for private hosted zones", *crd.CustomCELRules()[0].Message) + assert.Equal("size(self.name) > 0", crd.CustomCELRules()[1].Rule) + assert.Nil(crd.CustomCELRules()[1].Message) + + // Test field-level CEL rules on RecordSet.Name + recordSetCRD := getCRDByName("RecordSet", crds) + require.NotNil(recordSetCRD) + + nameField := recordSetCRD.SpecFields["Name"] + require.NotNil(nameField) + + require.Len(nameField.CustomCELRules(), 1) + assert.Equal("self.endsWith('.')", nameField.CustomCELRules()[0].Rule) + assert.NotNil(nameField.CustomCELRules()[0].Message) + assert.Equal("DNS name must end with a dot", *nameField.CustomCELRules()[0].Message) } diff --git a/pkg/testdata/models/apis/apigatewayv2/0000-00-00/generator-with-cel-rules.yaml b/pkg/testdata/models/apis/apigatewayv2/0000-00-00/generator-with-cel-rules.yaml new file mode 100644 index 00000000..8c4a71d9 --- /dev/null +++ b/pkg/testdata/models/apis/apigatewayv2/0000-00-00/generator-with-cel-rules.yaml @@ -0,0 +1,20 @@ +resources: + Authorizer: + fields: + JwtConfiguration.Issuer: + custom_cel_rules: + - rule: "self.startsWith('https://')" + message: "Issuer must be an HTTPS URL" +ignore: + resource_names: + - Api + - ApiMapping + - Deployment + - DomainName + - Integration + - IntegrationResponse + - Model + - Route + - RouteResponse + - Stage + - VpcLink diff --git a/pkg/testdata/models/apis/route53/0000-00-00/generator.yaml b/pkg/testdata/models/apis/route53/0000-00-00/generator.yaml index 91ccce5f..33335a59 100644 --- a/pkg/testdata/models/apis/route53/0000-00-00/generator.yaml +++ b/pkg/testdata/models/apis/route53/0000-00-00/generator.yaml @@ -18,6 +18,11 @@ operations: resource_name: RecordSet resources: + HostedZone: + custom_cel_rules: + - rule: "!has(self.hostedZoneConfig) || !self.hostedZoneConfig.privateZone || has(self.vpc)" + message: "spec.vpc is required for private hosted zones" + - rule: "size(self.name) > 0" RecordSet: fields: AliasTarget: @@ -61,6 +66,9 @@ resources: operation: ListResourceRecordSets path: ResourceRecordSets.Name is_immutable: true + custom_cel_rules: + - rule: "self.endsWith('.')" + message: "DNS name must end with a dot" # Changing this value after a CR has been created could result in orphaned record sets RecordType: from: diff --git a/templates/apis/crd.go.tpl b/templates/apis/crd.go.tpl index 67343b42..8347bbdd 100644 --- a/templates/apis/crd.go.tpl +++ b/templates/apis/crd.go.tpl @@ -15,6 +15,9 @@ import ( ) {{ .CRD.Documentation }} +{{- range $rule := .CRD.CustomCELRules }} +// +kubebuilder:validation:XValidation:rule="{{ $rule.Rule }}"{{- if $rule.Message }},message="{{ $rule.Message | Deref }}"{{- end }} +{{- end }} type {{ .CRD.Kind }}Spec struct { {{ range $fieldName := .CRD.SpecFieldNames }} {{- $field := (index $.CRD.SpecFields $fieldName) }} @@ -26,6 +29,10 @@ type {{ .CRD.Kind }}Spec struct { // +kubebuilder:validation:XValidation:rule="self == oldSelf",message="Value is immutable once set" {{ end -}} +{{- range $rule := $field.CustomCELRules }} + // +kubebuilder:validation:XValidation:rule="{{ $rule.Rule }}"{{- if $rule.Message }},message="{{ $rule.Message | Deref }}"{{- end }} +{{ end -}} + {{- if and ($field.IsRequired) (not $field.HasReference) -}} // +kubebuilder:validation:Required {{ end -}} diff --git a/templates/apis/type_def.go.tpl b/templates/apis/type_def.go.tpl index 84032aeb..e412635f 100644 --- a/templates/apis/type_def.go.tpl +++ b/templates/apis/type_def.go.tpl @@ -11,6 +11,9 @@ type {{ .Names.Camel }} struct { {{- if $attr.IsImmutable }} // +kubebuilder:validation:XValidation:rule="self == oldSelf",message="Value is immutable once set" {{- end }} + {{- range $rule := $attr.CustomCELRules }} + // +kubebuilder:validation:XValidation:rule="{{ $rule.Rule }}"{{- if $rule.Message }},message="{{ $rule.Message | Deref }}"{{- end }} + {{- end }} {{ $attr.Names.Camel }} {{ $attr.GoType }} {{ $attr.GetGoTag }} {{- end }} }