Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
10 changes: 10 additions & 0 deletions pkg/config/field.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
64 changes: 64 additions & 0 deletions pkg/config/field_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
4 changes: 4 additions & 0 deletions pkg/config/resource.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions pkg/generate/ack/apis.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,12 @@ var (
apisCopyPaths = []string{}
apisFuncMap = ttpl.FuncMap{
"Join": strings.Join,
"Deref": func(s *string) string {
if s == nil {
return ""
}
return *s
},
}
)

Expand Down
12 changes: 7 additions & 5 deletions pkg/model/attr.go
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
10 changes: 10 additions & 0 deletions pkg/model/crd.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
9 changes: 9 additions & 0 deletions pkg/model/field.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
18 changes: 18 additions & 0 deletions pkg/model/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
29 changes: 29 additions & 0 deletions pkg/model/model_apigwv2_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
36 changes: 36 additions & 0 deletions pkg/model/model_route53_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Original file line number Diff line number Diff line change
@@ -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
8 changes: 8 additions & 0 deletions pkg/testdata/models/apis/route53/0000-00-00/generator.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
7 changes: 7 additions & 0 deletions templates/apis/crd.go.tpl
Original file line number Diff line number Diff line change
Expand Up @@ -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) }}
Expand All @@ -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 -}}
Expand Down
3 changes: 3 additions & 0 deletions templates/apis/type_def.go.tpl
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
}
Expand Down