Skip to content

Commit 8a97293

Browse files
committed
feat: add cel support to specs and fields
1 parent 5ac6c79 commit 8a97293

16 files changed

Lines changed: 368 additions & 17 deletions

File tree

pkg/config/field.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -384,6 +384,13 @@ type ReferencesConfig struct {
384384
Path string `json:"path"`
385385
}
386386

387+
// CELRule represents a single CEL (Common Expression Language) validation rule
388+
// to be emitted as a +kubebuilder:validation:XValidation marker.
389+
type CELRule struct {
390+
Rule string `json:"rule"`
391+
Message *string `json:"message,omitempty"`
392+
}
393+
387394
// FieldConfig contains instructions to the code generator about how
388395
// to interpret the value of an Attribute and how to map it to a CRD's Spec or
389396
// Status field
@@ -492,6 +499,9 @@ type FieldConfig struct {
492499
//
493500
// (See https://github.com/aws-controllers-k8s/pkg/blob/main/names/names.go)
494501
GoTag *string `json:"go_tag,omitempty"`
502+
// CustomCELRules contains CEL validation rules emitted as
503+
// +kubebuilder:validation:XValidation markers on this field.
504+
CustomCELRules []CELRule `json:"custom_cel_rules,omitempty"`
495505
}
496506

497507
// GetFieldConfigs returns all FieldConfigs for a given resource as a map.

pkg/config/field_test.go

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
// Copyright Amazon.com Inc. or its affiliates. All Rights Reserved.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License"). You may
4+
// not use this file except in compliance with the License. A copy of the
5+
// License is located at
6+
//
7+
// http://aws.amazon.com/apache2.0/
8+
//
9+
// or in the "license" file accompanying this file. This file is distributed
10+
// on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
11+
// express or implied. See the License for the specific language governing
12+
// permissions and limitations under the License.
13+
14+
package config
15+
16+
import (
17+
"testing"
18+
19+
"github.com/stretchr/testify/assert"
20+
"github.com/stretchr/testify/require"
21+
"sigs.k8s.io/yaml"
22+
)
23+
24+
func TestCELRule_Parsing(t *testing.T) {
25+
require := require.New(t)
26+
assert := assert.New(t)
27+
28+
yamlStr := `
29+
resources:
30+
MyResource:
31+
custom_cel_rules:
32+
- rule: "has(self.foo)"
33+
message: "foo is required"
34+
- rule: "self.bar > 0"
35+
fields:
36+
MyField:
37+
custom_cel_rules:
38+
- rule: "self.matches('^[a-z]+')"
39+
message: "must be lowercase"
40+
`
41+
cfg, err := New("", Config{})
42+
require.Nil(err)
43+
err = yaml.UnmarshalStrict([]byte(yamlStr), &cfg)
44+
require.Nil(err)
45+
46+
resConfig, ok := cfg.Resources["MyResource"]
47+
require.True(ok)
48+
49+
// Resource-level rules
50+
require.Len(resConfig.CustomCELRules, 2)
51+
assert.Equal("has(self.foo)", resConfig.CustomCELRules[0].Rule)
52+
require.NotNil(resConfig.CustomCELRules[0].Message)
53+
assert.Equal("foo is required", *resConfig.CustomCELRules[0].Message)
54+
assert.Equal("self.bar > 0", resConfig.CustomCELRules[1].Rule)
55+
assert.Nil(resConfig.CustomCELRules[1].Message) // no message key
56+
57+
// Field-level rules
58+
fieldConfig, ok := resConfig.Fields["MyField"]
59+
require.True(ok)
60+
require.Len(fieldConfig.CustomCELRules, 1)
61+
assert.Equal("self.matches('^[a-z]+')", fieldConfig.CustomCELRules[0].Rule)
62+
require.NotNil(fieldConfig.CustomCELRules[0].Message)
63+
assert.Equal("must be lowercase", *fieldConfig.CustomCELRules[0].Message)
64+
}

pkg/config/resource.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,10 @@ type ResourceConfig struct {
122122
// SDK implementation details that are auto-filled by the SDK middleware
123123
// when nil and should not be exposed in the CRD.
124124
IgnoreIdempotencyToken bool `json:"ignore_idempotency_token,omitempty"`
125+
// CustomCELRules contains CEL validation rules emitted as
126+
// +kubebuilder:validation:XValidation markers on the Spec struct,
127+
// enabling cross-field validation.
128+
CustomCELRules []CELRule `json:"custom_cel_rules,omitempty"`
125129
}
126130

127131
// TagConfig instructs the code generator on how to generate functions that

pkg/generate/ack/apis.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,12 @@ var (
4040
apisCopyPaths = []string{}
4141
apisFuncMap = ttpl.FuncMap{
4242
"Join": strings.Join,
43+
"Deref": func(s *string) string {
44+
if s == nil {
45+
return ""
46+
}
47+
return *s
48+
},
4349
}
4450
)
4551

pkg/generate/ack/cel_test.go

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
// Copyright Amazon.com Inc. or its affiliates. All Rights Reserved.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License"). You may
4+
// not use this file except in compliance with the License. A copy of the
5+
// License is located at
6+
//
7+
// http://aws.amazon.com/apache2.0/
8+
//
9+
// or in the "license" file accompanying this file. This file is distributed
10+
// on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
11+
// express or implied. See the License for the specific language governing
12+
// permissions and limitations under the License.
13+
14+
package ack_test
15+
16+
import (
17+
"strings"
18+
"testing"
19+
20+
"github.com/stretchr/testify/assert"
21+
"github.com/stretchr/testify/require"
22+
23+
ackgenerate "github.com/aws-controllers-k8s/code-generator/pkg/generate/ack"
24+
"github.com/aws-controllers-k8s/code-generator/pkg/testutil"
25+
)
26+
27+
// TestCELOnTypeDef verifies that custom_cel_rules configured on a
28+
// nested field are rendered as +kubebuilder:validation:XValidation markers in
29+
// the generated types.go (via the type_def template include).
30+
func TestCELOnTypeDef(t *testing.T) {
31+
assert := assert.New(t)
32+
require := require.New(t)
33+
34+
g := testutil.NewModelForServiceWithOptions(t, "apigatewayv2", &testutil.TestingModelOptions{
35+
GeneratorConfigFile: "generator-with-cel-rules.yaml",
36+
})
37+
38+
ts, err := ackgenerate.APIs(g, []string{testutil.TemplatesBasePath(t)})
39+
require.NoError(err)
40+
require.NoError(ts.Execute())
41+
42+
typesGo, ok := ts.Executed()["types.go"]
43+
require.True(ok, "types.go not found in executed templates")
44+
45+
output := typesGo.String()
46+
assert.True(
47+
strings.Contains(output, `// +kubebuilder:validation:XValidation:rule="self.startsWith('https://')",message="Issuer must be an HTTPS URL"`),
48+
"expected XValidation marker for Issuer CEL rule in types.go",
49+
)
50+
}
51+
52+
// TestCELOnSpec verifies that custom_cel_rules configured at the
53+
// resource level are rendered as +kubebuilder:validation:XValidation markers
54+
// on the generated CRD Spec struct (via the crd.go template).
55+
func TestCELOnSpec(t *testing.T) {
56+
assert := assert.New(t)
57+
require := require.New(t)
58+
59+
g := testutil.NewModelForService(t, "route53")
60+
61+
ts, err := ackgenerate.APIs(g, []string{testutil.TemplatesBasePath(t)})
62+
require.NoError(err)
63+
require.NoError(ts.Execute())
64+
65+
hostedZoneGo, ok := ts.Executed()["hosted_zone.go"]
66+
require.True(ok, "hosted_zone.go not found in executed templates")
67+
68+
output := hostedZoneGo.String()
69+
assert.True(
70+
strings.Contains(output, `// +kubebuilder:validation:XValidation:rule="!has(self.hostedZoneConfig) || !self.hostedZoneConfig.privateZone || has(self.vpc)",message="spec.vpc is required for private hosted zones"`),
71+
"expected XValidation marker for first HostedZone CEL rule",
72+
)
73+
assert.True(
74+
strings.Contains(output, `// +kubebuilder:validation:XValidation:rule="size(self.name) > 0"`),
75+
"expected XValidation marker for second HostedZone CEL rule (no message)",
76+
)
77+
}
78+
79+
// TestCELOnField verifies that custom_cel_rules configured on a
80+
// top-level spec field are rendered as +kubebuilder:validation:XValidation
81+
// markers on the individual field in the generated CRD Spec struct.
82+
func TestCELOnField(t *testing.T) {
83+
assert := assert.New(t)
84+
require := require.New(t)
85+
86+
g := testutil.NewModelForService(t, "route53")
87+
88+
ts, err := ackgenerate.APIs(g, []string{testutil.TemplatesBasePath(t)})
89+
require.NoError(err)
90+
require.NoError(ts.Execute())
91+
92+
recordSetGo, ok := ts.Executed()["record_set.go"]
93+
require.True(ok, "record_set.go not found in executed templates")
94+
95+
output := recordSetGo.String()
96+
assert.True(
97+
strings.Contains(output, `// +kubebuilder:validation:XValidation:rule="self.endsWith('.')",message="DNS name must end with a dot"`),
98+
"expected XValidation marker for Name field CEL rule in record_set.go",
99+
)
100+
}

pkg/model/attr.go

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,15 +17,17 @@ import (
1717
"fmt"
1818

1919
awssdkmodel "github.com/aws-controllers-k8s/code-generator/pkg/api"
20+
ackgenconfig "github.com/aws-controllers-k8s/code-generator/pkg/config"
2021
"github.com/aws-controllers-k8s/pkg/names"
2122
)
2223

2324
type Attr struct {
24-
Names names.Names
25-
GoType string
26-
Shape *awssdkmodel.Shape
27-
GoTag string
28-
IsImmutable bool
25+
Names names.Names
26+
GoType string
27+
Shape *awssdkmodel.Shape
28+
GoTag string
29+
IsImmutable bool
30+
CustomCELRules []ackgenconfig.CELRule
2931
}
3032

3133
func NewAttr(

pkg/model/crd.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -422,6 +422,16 @@ func (r *CRD) IsARNPrimaryKey() bool {
422422
return resGenConfig.IsARNPrimaryKey
423423
}
424424

425+
// CustomCELRules returns the custom CEL validation rules configured for this
426+
// resource's Spec struct, or nil if none are configured.
427+
func (r *CRD) CustomCELRules() []ackgenconfig.CELRule {
428+
resGenConfig := r.cfg.GetResourceConfig(r.Names.Original)
429+
if resGenConfig == nil {
430+
return nil
431+
}
432+
return resGenConfig.CustomCELRules
433+
}
434+
425435
// GetPrimaryKeyField returns the field designated as the primary key, nil if
426436
// none are specified or an error if multiple are designated.
427437
func (r *CRD) GetPrimaryKeyField() (*Field, error) {

pkg/model/field.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,15 @@ func (f *Field) IsImmutable() bool {
209209
return false
210210
}
211211

212+
// CustomCELRules returns the custom CEL validation rules configured for
213+
// this field, or nil if none are configured.
214+
func (f *Field) CustomCELRules() []ackgenconfig.CELRule {
215+
if f.FieldConfig != nil {
216+
return f.FieldConfig.CustomCELRules
217+
}
218+
return nil
219+
}
220+
212221
// GetSetterConfig returns the SetFieldConfig object associated with this field
213222
// and a supplied operation type, or nil if none exists.
214223
func (f *Field) GetSetterConfig(opType OpType) *ackgenconfig.SetFieldConfig {

pkg/model/model.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -761,6 +761,11 @@ func (m *Model) processNestedFieldTypeDefs(
761761
return fmt.Errorf("resource %q, field %q: %w", crd.Names.Original, fieldPath, err)
762762
}
763763
}
764+
if len(field.CustomCELRules()) > 0 {
765+
if err := setTypeDefAttributeCELRules(crd, fieldPath, field.CustomCELRules(), tdefs); err != nil {
766+
return fmt.Errorf("resource %q, field %q: %w", crd.Names.Original, fieldPath, err)
767+
}
768+
}
764769
}
765770
}
766771
return nil
@@ -909,6 +914,19 @@ func setTypeDefAttributeImmutable(crd *CRD, fieldPath string, tdefs []*TypeDef)
909914
return nil
910915
}
911916

917+
// setTypeDefAttributeCELRules sets the CustomCELRules on the Attr corresponding
918+
// to the nested field at fieldPath.
919+
func setTypeDefAttributeCELRules(crd *CRD, fieldPath string, rules []ackgenconfig.CELRule, tdefs []*TypeDef) error {
920+
_, fieldAttr, err := getAttributeFromPath(crd, fieldPath, tdefs)
921+
if err != nil {
922+
return err
923+
}
924+
if fieldAttr != nil {
925+
fieldAttr.CustomCELRules = rules
926+
}
927+
return nil
928+
}
929+
912930
// updateTypeDefAttributeWithReference adds a new AWSResourceReference attribute
913931
// for the corresponding attribute represented by fieldPath of nested field
914932
func updateTypeDefAttributeWithReference(crd *CRD, fieldPath string, tdefs []*TypeDef) error {

pkg/model/model_apigwv2_test.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,35 @@ func TestAPIGatewayV2_WithReference(t *testing.T) {
251251
assert.Equal(2, len(referencedServiceNames))
252252
}
253253

254+
func TestAPIGatewayV2_CustomCELRules_NestedField(t *testing.T) {
255+
require := require.New(t)
256+
assert := assert.New(t)
257+
258+
g := testutil.NewModelForServiceWithOptions(t, "apigatewayv2", &testutil.TestingModelOptions{
259+
GeneratorConfigFile: "generator-with-cel-rules.yaml",
260+
})
261+
262+
tds, err := g.GetTypeDefs()
263+
require.Nil(err)
264+
require.NotNil(tds)
265+
266+
var tdef *model.TypeDef
267+
for _, td := range tds {
268+
if td != nil && strings.EqualFold(td.Names.Original, "jwtConfiguration") {
269+
tdef = td
270+
break
271+
}
272+
}
273+
require.NotNil(tdef, "JWTConfiguration TypeDef must exist — check SDK shape name casing if nil")
274+
275+
issuerAttr, ok := tdef.Attrs["Issuer"]
276+
require.True(ok, "Issuer attr must exist in JWTConfiguration TypeDef")
277+
require.Len(issuerAttr.CustomCELRules, 1)
278+
assert.Equal("self.startsWith('https://')", issuerAttr.CustomCELRules[0].Rule)
279+
require.NotNil(issuerAttr.CustomCELRules[0].Message)
280+
assert.Equal("Issuer must be an HTTPS URL", *issuerAttr.CustomCELRules[0].Message)
281+
}
282+
254283
func TestAPIGatewayV2_WithNestedReference(t *testing.T) {
255284
_ = assert.New(t)
256285
require := require.New(t)

0 commit comments

Comments
 (0)