Skip to content

Commit 7c982e0

Browse files
authored
✨ crd/marker: add AtMostOneOf and ExactlyOneOf constraints (#1212)
* crd/marker: add AtMostOneOf and ExactlyOneOf constraints Adds the following validation markers to enforce oneof fields on Types: - AtMostOneOf: allows at most one of the specified fields, thereby allowing exactly 1 or no fields to be set. The marker may be repeated to allow mutually exclusive oneof constraints. - ExactlyOneOf: allows exactly one of the specified fields, thereby requiring exactly 1 field to be set. The marker may be repeated to allow mutually exclusive oneof constraints. Examples: ``` allow at most oneof foo,bar on a Type to be set +kubebuilder:validation:AtMostOneOf=foo;bar allow at most oneof foo,bar and oneof baz,qux to be set +kubebuilder:validation:AtMostOneOf=foo;ba +kubebuilder:validation:AtMostOneOf=baz;qux allow exactly oneof foo,bar on a Type to be set +kubebuilder:validation:ExactlyOneOf=foo;bar allow exactly oneof foo,bar and oneof baz,qux to be set +kubebuilder:validation:ExactlyOneOf=foo;ba +kubebuilder:validation:ExactlyOneOf=baz;qux ``` * switch to cel
1 parent d6321af commit 7c982e0

File tree

11 files changed

+834
-2
lines changed

11 files changed

+834
-2
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
*.swp
1717
*.swo
1818
*~
19+
.vscode/
1920

2021
# Tools binaries.
2122
out

go.mod

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,21 +16,34 @@ require (
1616
k8s.io/api v0.33.1
1717
k8s.io/apiextensions-apiserver v0.33.1
1818
k8s.io/apimachinery v0.33.1
19+
k8s.io/apiserver v0.33.1
1920
k8s.io/code-generator v0.33.1
2021
k8s.io/gengo/v2 v2.0.0-20250207200755-1244d31929d7
2122
k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738
2223
sigs.k8s.io/yaml v1.4.0
2324
)
2425

2526
require (
27+
cel.dev/expr v0.19.1 // indirect
28+
github.com/antlr4-go/antlr/v4 v4.13.0 // indirect
29+
github.com/beorn7/perks v1.0.1 // indirect
30+
github.com/blang/semver/v4 v4.0.0 // indirect
31+
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
32+
github.com/cespare/xxhash/v2 v2.3.0 // indirect
33+
github.com/davecgh/go-spew v1.1.1 // indirect
34+
github.com/felixge/httpsnoop v1.0.4 // indirect
2635
github.com/fsnotify/fsnotify v1.7.0 // indirect
2736
github.com/fxamacker/cbor/v2 v2.7.0 // indirect
2837
github.com/go-logr/logr v1.4.2 // indirect
38+
github.com/go-logr/stdr v1.2.2 // indirect
2939
github.com/go-openapi/jsonpointer v0.21.0 // indirect
3040
github.com/go-openapi/jsonreference v0.20.2 // indirect
3141
github.com/go-openapi/swag v0.23.0 // indirect
3242
github.com/gogo/protobuf v1.3.2 // indirect
43+
github.com/google/cel-go v0.23.2 // indirect
3344
github.com/google/gnostic-models v0.6.9 // indirect
45+
github.com/google/uuid v1.6.0 // indirect
46+
github.com/grpc-ecosystem/grpc-gateway/v2 v2.24.0 // indirect
3447
github.com/inconshreveable/mousetrap v1.1.0 // indirect
3548
github.com/josharian/intern v1.0.0 // indirect
3649
github.com/json-iterator/go v1.1.12 // indirect
@@ -39,18 +52,43 @@ require (
3952
github.com/mattn/go-isatty v0.0.20 // indirect
4053
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
4154
github.com/modern-go/reflect2 v1.0.2 // indirect
55+
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
4256
github.com/nxadm/tail v1.4.8 // indirect
57+
github.com/prometheus/client_golang v1.22.0 // indirect
58+
github.com/prometheus/client_model v0.6.1 // indirect
59+
github.com/prometheus/common v0.62.0 // indirect
60+
github.com/prometheus/procfs v0.15.1 // indirect
61+
github.com/stoewer/go-strcase v1.3.0 // indirect
4362
github.com/x448/float16 v0.8.4 // indirect
63+
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
64+
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0 // indirect
65+
go.opentelemetry.io/otel v1.33.0 // indirect
66+
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.33.0 // indirect
67+
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.33.0 // indirect
68+
go.opentelemetry.io/otel/metric v1.33.0 // indirect
69+
go.opentelemetry.io/otel/sdk v1.33.0 // indirect
70+
go.opentelemetry.io/otel/trace v1.33.0 // indirect
71+
go.opentelemetry.io/proto/otlp v1.4.0 // indirect
72+
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect
4473
golang.org/x/mod v0.25.0 // indirect
4574
golang.org/x/net v0.41.0 // indirect
75+
golang.org/x/oauth2 v0.27.0 // indirect
4676
golang.org/x/sync v0.15.0 // indirect
4777
golang.org/x/sys v0.33.0 // indirect
78+
golang.org/x/term v0.32.0 // indirect
4879
golang.org/x/text v0.26.0 // indirect
80+
golang.org/x/time v0.9.0 // indirect
81+
google.golang.org/genproto/googleapis/api v0.0.0-20241209162323-e6fa225c2576 // indirect
82+
google.golang.org/genproto/googleapis/rpc v0.0.0-20241209162323-e6fa225c2576 // indirect
83+
google.golang.org/grpc v1.68.1 // indirect
4984
google.golang.org/protobuf v1.36.5 // indirect
5085
gopkg.in/inf.v0 v0.9.1 // indirect
5186
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect
87+
k8s.io/client-go v0.33.1 // indirect
88+
k8s.io/component-base v0.33.1 // indirect
5289
k8s.io/klog/v2 v2.130.1 // indirect
5390
k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff // indirect
91+
sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.2 // indirect
5492
sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 // indirect
5593
sigs.k8s.io/randfill v1.0.0 // indirect
5694
sigs.k8s.io/structured-merge-diff/v4 v4.6.0 // indirect

go.sum

Lines changed: 111 additions & 0 deletions
Large diffs are not rendered by default.

pkg/crd/markers/validation.go

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,9 @@ const (
3131

3232
SchemalessName = "kubebuilder:validation:Schemaless"
3333
ValidationItemsPrefix = validationPrefix + "items:"
34+
35+
ValidationExactlyOneOfPrefix = validationPrefix + "ExactlyOneOf"
36+
ValidationAtMostOneOfPrefix = validationPrefix + "AtMostOneOf"
3437
)
3538

3639
// ValidationMarkers lists all available markers that affect CRD schema generation,
@@ -75,6 +78,14 @@ var ValidationMarkers = mustMakeAllWithPrefix(validationPrefix, markers.Describe
7578
XValidation{},
7679
)
7780

81+
// TypeOnlyMarkers list type-specific validation markers (i.e. those markers that don't make sense on a field, and thus aren't in ValidationMarkers or FieldOnlyMarkers).
82+
var TypeOnlyMarkers = []*definitionWithHelp{
83+
must(markers.MakeDefinition(ValidationAtMostOneOfPrefix, markers.DescribesType, AtMostOneOf(nil))).
84+
WithHelp(markers.SimpleHelp("CRD validation", "specifies a list of field names that must conform to the AtMostOneOf constraint.")),
85+
must(markers.MakeDefinition(ValidationExactlyOneOfPrefix, markers.DescribesType, ExactlyOneOf(nil))).
86+
WithHelp(markers.SimpleHelp("CRD validation", "specifies a list of field names that must conform to the ExactlyOneOf constraint.")),
87+
}
88+
7889
// FieldOnlyMarkers list field-specific validation markers (i.e. those markers that don't make
7990
// sense on a type, and thus aren't in ValidationMarkers).
8091
var FieldOnlyMarkers = []*definitionWithHelp{
@@ -141,6 +152,7 @@ func init() {
141152
}
142153

143154
AllDefinitions = append(AllDefinitions, FieldOnlyMarkers...)
155+
AllDefinitions = append(AllDefinitions, TypeOnlyMarkers...)
144156
AllDefinitions = append(AllDefinitions, ValidationIshMarkers...)
145157
}
146158

@@ -352,6 +364,18 @@ type XValidation struct {
352364
OptionalOldSelf *bool `marker:"optionalOldSelf,optional"`
353365
}
354366

367+
// +controllertools:marker:generateHelp:category="CRD validation"
368+
// AtMostOneOf adds a validation constraint that allows at most one of the specified fields.
369+
//
370+
// This marker may be repeated to specify multiple AtMostOneOf constraints that are mutually exclusive.
371+
type AtMostOneOf []string
372+
373+
// +controllertools:marker:generateHelp:category="CRD validation"
374+
// ExactlyOneOf adds a validation constraint that allows at exactly one of the specified fields.
375+
//
376+
// This marker may be repeated to specify multiple ExactlyOneOf constraints that are mutually exclusive.
377+
type ExactlyOneOf []string
378+
355379
func (m Maximum) ApplyToSchema(schema *apiext.JSONSchemaProps) error {
356380
if !hasNumericType(schema) {
357381
return fmt.Errorf("must apply maximum to a numeric value, found %s", schema.Type)
@@ -635,3 +659,44 @@ func (m XValidation) ApplyToSchema(schema *apiext.JSONSchemaProps) error {
635659
})
636660
return nil
637661
}
662+
663+
func (fields AtMostOneOf) ApplyToSchema(schema *apiext.JSONSchemaProps) error {
664+
if len(fields) == 0 {
665+
return nil
666+
}
667+
rule := fieldsToOneOfCelRuleStr(fields)
668+
xvalidation := XValidation{
669+
Rule: fmt.Sprintf("%s <= 1", rule),
670+
Message: fmt.Sprintf("at most one of the fields in %v may be set", fields),
671+
}
672+
return xvalidation.ApplyToSchema(schema)
673+
}
674+
675+
func (fields ExactlyOneOf) ApplyToSchema(schema *apiext.JSONSchemaProps) error {
676+
if len(fields) == 0 {
677+
return nil
678+
}
679+
rule := fieldsToOneOfCelRuleStr(fields)
680+
xvalidation := XValidation{
681+
Rule: fmt.Sprintf("%s == 1", rule),
682+
Message: fmt.Sprintf("exactly one of the fields in %v must be set", fields),
683+
}
684+
return xvalidation.ApplyToSchema(schema)
685+
}
686+
687+
// fieldsToOneOfCelRuleStr converts a slice of field names to a string representation
688+
// [has(self.field1),has(self.field1),...].filter(x, x == true).size()
689+
func fieldsToOneOfCelRuleStr(fields []string) string {
690+
var list strings.Builder
691+
list.WriteString("[")
692+
for i, f := range fields {
693+
if i > 0 {
694+
list.WriteString(",")
695+
}
696+
list.WriteString("has(self.")
697+
list.WriteString(f)
698+
list.WriteString(")")
699+
}
700+
list.WriteString("].filter(x,x==true).size()")
701+
return list.String()
702+
}

pkg/crd/markers/zz_generated.markerhelp.go

Lines changed: 22 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pkg/crd/parser_integration_test.go

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,6 @@ func packageErrors(pkg *loader.Package, filterKinds ...packages.ErrorKind) error
5252

5353
var _ = Describe("CRD Generation From Parsing to CustomResourceDefinition", func() {
5454
Context("should properly generate and flatten the rewritten schemas", func() {
55-
5655
var (
5756
prevCwd string
5857
pkgPaths []string
@@ -177,6 +176,31 @@ var _ = Describe("CRD Generation From Parsing to CustomResourceDefinition", func
177176
})
178177
})
179178

179+
Context("OneOf API", func() {
180+
BeforeEach(func() {
181+
pkgPaths = []string{"./oneof/..."}
182+
expPkgLen = 1
183+
})
184+
It("should successfully generate the CRD with OneOf validation constraints", func() {
185+
assertCRD(pkgs[0], "Oneof", "testdata.kubebuilder.io_oneofs.yaml")
186+
})
187+
})
188+
189+
Context("OneOf API with invalid marker", func() {
190+
BeforeEach(func() {
191+
pkgPaths = []string{"./oneof_error/..."}
192+
expPkgLen = 1
193+
})
194+
It("should generate an error with nested field in marker", func() {
195+
kind := "Oneof"
196+
groupKind := schema.GroupKind{Kind: kind, Group: "testdata.kubebuilder.io"}
197+
parser.NeedCRDFor(groupKind, nil)
198+
199+
expectedErr := "kubebuilder:validation:AtMostOneOf: cannot reference nested fields: field.foo,field.bar"
200+
Expect(packageErrors(pkgs[0])).To(MatchError(ContainSubstring(expectedErr)))
201+
})
202+
})
203+
180204
Context("CronJob API with Wrong Annotation Format", func() {
181205
BeforeEach(func() {
182206
pkgPaths = []string{"./wrong_annotation_format"}
@@ -338,5 +362,4 @@ var _ = Describe("CRD Generation From Parsing to CustomResourceDefinition", func
338362
By("comparing the two")
339363
Expect(parser.CustomResourceDefinitions[groupKind]).To(Equal(crd), "type not as expected, check pkg/crd/testdata/README.md for more details.\n\nDiff:\n\n%s", cmp.Diff(parser.CustomResourceDefinitions[groupKind], crd))
340364
})
341-
342365
})

pkg/crd/schema.go

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import (
2626
"strings"
2727

2828
apiext "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
29+
"k8s.io/apimachinery/pkg/util/sets"
2930
crdmarkers "sigs.k8s.io/controller-tools/pkg/crd/markers"
3031
"sigs.k8s.io/controller-tools/pkg/loader"
3132
"sigs.k8s.io/controller-tools/pkg/markers"
@@ -397,6 +398,8 @@ func mapToSchema(ctx *schemaContext, mapType *ast.MapType) *apiext.JSONSchemaPro
397398

398399
// structToSchema creates a schema for the given struct. Embedded fields are placed in AllOf,
399400
// and can be flattened later with a Flattener.
401+
//
402+
//nolint:gocyclo
400403
func structToSchema(ctx *schemaContext, structType *ast.StructType) *apiext.JSONSchemaProps {
401404
props := &apiext.JSONSchemaProps{
402405
Type: "object",
@@ -408,6 +411,17 @@ func structToSchema(ctx *schemaContext, structType *ast.StructType) *apiext.JSON
408411
return props
409412
}
410413

414+
exactlyOneOf, err := oneOfValuesToSet(ctx.info.Markers[crdmarkers.ValidationExactlyOneOfPrefix])
415+
if err != nil {
416+
ctx.pkg.AddError(loader.ErrFromNode(err, structType))
417+
return props
418+
}
419+
atMostOneOf, err := oneOfValuesToSet(ctx.info.Markers[crdmarkers.ValidationAtMostOneOfPrefix])
420+
if err != nil {
421+
ctx.pkg.AddError(loader.ErrFromNode(err, structType))
422+
return props
423+
}
424+
411425
for _, field := range ctx.info.Fields {
412426
// Skip if the field is not an inline field, ignoreUnexportedFields is true, and the field is not exported
413427
if field.Name != "" && ctx.ignoreUnexportedFields && !ast.IsExported(field.Name) {
@@ -449,18 +463,30 @@ func structToSchema(ctx *schemaContext, structType *ast.StructType) *apiext.JSON
449463
case field.Markers.Get("kubebuilder:validation:Optional") != nil:
450464
// explicitly optional - kubebuilder
451465
case field.Markers.Get("kubebuilder:validation:Required") != nil:
466+
if exactlyOneOf.Has(fieldName) || atMostOneOf.Has(fieldName) {
467+
ctx.pkg.AddError(loader.ErrFromNode(fmt.Errorf("field %s is part of OneOf constraint and cannot be marked as required", fieldName), structType))
468+
return props
469+
}
452470
// explicitly required - kubebuilder
453471
props.Required = append(props.Required, fieldName)
454472
case field.Markers.Get("optional") != nil:
455473
// explicitly optional - kubernetes
456474
case field.Markers.Get("required") != nil:
475+
if exactlyOneOf.Has(fieldName) || atMostOneOf.Has(fieldName) {
476+
ctx.pkg.AddError(loader.ErrFromNode(fmt.Errorf("field %s is part of OneOf constraint and cannot be marked as required", fieldName), structType))
477+
return props
478+
}
457479
// explicitly required - kubernetes
458480
props.Required = append(props.Required, fieldName)
459481

460482
// if this package isn't set to optional default...
461483
case defaultMode == "required":
462484
// ...everything that's not inline / omitempty is required
463485
if !inline && !omitEmpty {
486+
if exactlyOneOf.Has(fieldName) || atMostOneOf.Has(fieldName) {
487+
ctx.pkg.AddError(loader.ErrFromNode(fmt.Errorf("field %s is part of OneOf constraint and must have omitempty tag", fieldName), structType))
488+
return props
489+
}
464490
props.Required = append(props.Required, fieldName)
465491
}
466492

@@ -490,6 +516,41 @@ func structToSchema(ctx *schemaContext, structType *ast.StructType) *apiext.JSON
490516
return props
491517
}
492518

519+
func oneOfValuesToSet(oneOfGroups []any) (sets.Set[string], error) {
520+
set := sets.New[string]()
521+
for _, oneOf := range oneOfGroups {
522+
switch vals := oneOf.(type) {
523+
case crdmarkers.ExactlyOneOf:
524+
if err := validateOneOfValues(vals...); err != nil {
525+
return nil, fmt.Errorf("%s: %w", crdmarkers.ValidationExactlyOneOfPrefix, err)
526+
}
527+
set.Insert(vals...)
528+
case crdmarkers.AtMostOneOf:
529+
if err := validateOneOfValues(vals...); err != nil {
530+
return nil, fmt.Errorf("%s: %w", crdmarkers.ValidationAtMostOneOfPrefix, err)
531+
}
532+
set.Insert(vals...)
533+
default:
534+
return nil, fmt.Errorf("expected ExactlyOneOf or AtMostOneOf, got %T", oneOf)
535+
}
536+
}
537+
return set, nil
538+
}
539+
540+
func validateOneOfValues(fields ...string) error {
541+
var invalid []string
542+
for _, field := range fields {
543+
if strings.Contains(field, ".") {
544+
// nested fields are not allowed in OneOf validation markers
545+
invalid = append(invalid, field)
546+
}
547+
}
548+
if len(invalid) > 0 {
549+
return fmt.Errorf("cannot reference nested fields: %s", strings.Join(invalid, ","))
550+
}
551+
return nil
552+
}
553+
493554
// builtinToType converts builtin basic types to their equivalent JSON schema form.
494555
// It *only* handles types allowed by the kubernetes API standards. Floats are not
495556
// allowed unless allowDangerousTypes is true

0 commit comments

Comments
 (0)