Skip to content

Commit 8f0c1b1

Browse files
authored
✨crd/marker: add AtLeastOneOf constraint (#1278)
* crd/marker: add AtLeastOneOf * address comments
1 parent 03cd0b7 commit 8f0c1b1

File tree

5 files changed

+120
-6
lines changed

5 files changed

+120
-6
lines changed

pkg/crd/markers/validation.go

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ const (
3434

3535
ValidationExactlyOneOfPrefix = validationPrefix + "ExactlyOneOf"
3636
ValidationAtMostOneOfPrefix = validationPrefix + "AtMostOneOf"
37+
ValidationAtLeastOneOfPrefix = validationPrefix + "AtLeastOneOf"
3738
)
3839

3940
// ValidationMarkers lists all available markers that affect CRD schema generation,
@@ -87,6 +88,8 @@ var TypeOnlyMarkers = []*definitionWithHelp{
8788
WithHelp(markers.SimpleHelp("CRD validation", "specifies a list of field names that must conform to the AtMostOneOf constraint.")),
8889
must(markers.MakeDefinition(ValidationExactlyOneOfPrefix, markers.DescribesType, ExactlyOneOf(nil))).
8990
WithHelp(markers.SimpleHelp("CRD validation", "specifies a list of field names that must conform to the ExactlyOneOf constraint.")),
91+
must(markers.MakeDefinition(ValidationAtLeastOneOfPrefix, markers.DescribesType, AtLeastOneOf(nil))).
92+
WithHelp(markers.SimpleHelp("CRD validation", "specifies a list of field names that must conform to the AtLeastOneOf constraint.")),
9093
}
9194

9295
// FieldOnlyMarkers list field-specific validation markers (i.e. those markers that don't make
@@ -381,6 +384,12 @@ type AtMostOneOf []string
381384
// This marker may be repeated to specify multiple ExactlyOneOf constraints that are mutually exclusive.
382385
type ExactlyOneOf []string
383386

387+
// +controllertools:marker:generateHelp:category="CRD validation"
388+
// AtLeastOneOf adds a validation constraint that allows at least one of the specified fields.
389+
//
390+
// This marker may be repeated to specify multiple AtLeastOneOf constraints that are mutually exclusive.
391+
type AtLeastOneOf []string
392+
384393
func (m Maximum) ApplyToSchema(schema *apiext.JSONSchemaProps) error {
385394
if !hasNumericType(schema) {
386395
return fmt.Errorf("must apply maximum to a numeric value, found %s", schema.Type)
@@ -699,8 +708,25 @@ func (fields ExactlyOneOf) ApplyToSchema(schema *apiext.JSONSchemaProps) error {
699708
}
700709

701710
func (ExactlyOneOf) ApplyPriority() ApplyPriority {
702-
// explicitly go after XValidation markers so that the ordering is deterministic
703-
return XValidation{}.ApplyPriority() + 1
711+
// explicitly go after AtMostOneOf markers so that the ordering is deterministic
712+
return AtMostOneOf{}.ApplyPriority() + 1
713+
}
714+
715+
func (fields AtLeastOneOf) ApplyToSchema(schema *apiext.JSONSchemaProps) error {
716+
if len(fields) == 0 {
717+
return nil
718+
}
719+
rule := fieldsToOneOfCelRuleStr(fields)
720+
xvalidation := XValidation{
721+
Rule: fmt.Sprintf("%s >= 1", rule),
722+
Message: fmt.Sprintf("at least one of the fields in %v must be set", fields),
723+
}
724+
return xvalidation.ApplyToSchema(schema)
725+
}
726+
727+
func (AtLeastOneOf) ApplyPriority() ApplyPriority {
728+
// explicitly go after ExactlyOneOf markers so that the ordering is deterministic
729+
return ExactlyOneOf{}.ApplyPriority() + 1
704730
}
705731

706732
// fieldsToOneOfCelRuleStr converts a slice of field names to a string representation

pkg/crd/schema.go

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -419,6 +419,11 @@ func structToSchema(ctx *schemaContext, structType *ast.StructType) *apiext.JSON
419419
ctx.pkg.AddError(loader.ErrFromNode(err, structType))
420420
return props
421421
}
422+
atLeastOneOf, err := oneOfValuesToSet(ctx.info.Markers[crdmarkers.ValidationAtLeastOneOfPrefix])
423+
if err != nil {
424+
ctx.pkg.AddError(loader.ErrFromNode(err, structType))
425+
return props
426+
}
422427

423428
for _, field := range ctx.info.Fields {
424429
// Skip if the field is not an inline field, ignoreUnexportedFields is true, and the field is not exported
@@ -461,7 +466,7 @@ func structToSchema(ctx *schemaContext, structType *ast.StructType) *apiext.JSON
461466
case field.Markers.Get("kubebuilder:validation:Optional") != nil:
462467
// explicitly optional - kubebuilder
463468
case field.Markers.Get("kubebuilder:validation:Required") != nil:
464-
if exactlyOneOf.Has(fieldName) || atMostOneOf.Has(fieldName) {
469+
if exactlyOneOf.Has(fieldName) || atMostOneOf.Has(fieldName) || atLeastOneOf.Has(fieldName) {
465470
ctx.pkg.AddError(loader.ErrFromNode(fmt.Errorf("field %s is part of OneOf constraint and cannot be marked as required", fieldName), structType))
466471
return props
467472
}
@@ -470,7 +475,7 @@ func structToSchema(ctx *schemaContext, structType *ast.StructType) *apiext.JSON
470475
case field.Markers.Get("optional") != nil:
471476
// explicitly optional - kubernetes
472477
case field.Markers.Get("required") != nil:
473-
if exactlyOneOf.Has(fieldName) || atMostOneOf.Has(fieldName) {
478+
if exactlyOneOf.Has(fieldName) || atMostOneOf.Has(fieldName) || atLeastOneOf.Has(fieldName) {
474479
ctx.pkg.AddError(loader.ErrFromNode(fmt.Errorf("field %s is part of OneOf constraint and cannot be marked as required", fieldName), structType))
475480
return props
476481
}
@@ -481,7 +486,7 @@ func structToSchema(ctx *schemaContext, structType *ast.StructType) *apiext.JSON
481486
case defaultMode == "required":
482487
// ...everything that's not inline / omitempty is required
483488
if !inline && !omitEmpty {
484-
if exactlyOneOf.Has(fieldName) || atMostOneOf.Has(fieldName) {
489+
if exactlyOneOf.Has(fieldName) || atMostOneOf.Has(fieldName) || atLeastOneOf.Has(fieldName) {
485490
ctx.pkg.AddError(loader.ErrFromNode(fmt.Errorf("field %s is part of OneOf constraint and must have omitempty tag", fieldName), structType))
486491
return props
487492
}
@@ -533,6 +538,11 @@ func oneOfValuesToSet(oneOfGroups []any) (sets.Set[string], error) {
533538
return nil, fmt.Errorf("%s: %w", crdmarkers.ValidationAtMostOneOfPrefix, err)
534539
}
535540
set.Insert(vals...)
541+
case crdmarkers.AtLeastOneOf:
542+
if err := validateOneOfValues(vals...); err != nil {
543+
return nil, fmt.Errorf("%s: %w", crdmarkers.ValidationAtLeastOneOfPrefix, err)
544+
}
545+
set.Insert(vals...)
536546
default:
537547
return nil, fmt.Errorf("expected ExactlyOneOf or AtMostOneOf, got %T", oneOf)
538548
}

pkg/crd/testdata/oneof/types.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,10 @@ type OneofSpec struct {
3232

3333
FirstTypeWithExactOneof *TypeWithExactOneofs `json:"firstTypeWithExactOneof,omitempty"`
3434
SecondTypeWithExactOneof *TypeWithMultipleExactOneofs `json:"secondTypeWithExactOneof,omitempty"`
35+
36+
TypeWithMultipleAtLeastOneofs *TypeWithMultipleAtLeastOneofs `json:"typeWithMultipleAtLeastOneOf,omitempty"`
37+
38+
TypeWithAllOneOf *TypeWithAllOneofs `json:"typeWithAllOneOf,omitempty"`
3539
}
3640

3741
// +kubebuilder:validation:XValidation:message="only one of foo|bar may be set",rule="!(has(self.foo) && has(self.bar))"
@@ -67,6 +71,30 @@ type TypeWithMultipleExactOneofs struct {
6771
D *string `json:"d,omitempty"`
6872
}
6973

74+
// +kubebuilder:validation:AtLeastOneOf=a;b
75+
// +kubebuilder:validation:AtLeastOneOf=c;d
76+
type TypeWithMultipleAtLeastOneofs struct {
77+
A *string `json:"a,omitempty"`
78+
B *string `json:"b,omitempty"`
79+
80+
C *string `json:"c,omitempty"`
81+
D *string `json:"d,omitempty"`
82+
}
83+
84+
// +kubebuilder:validation:AtMostOneOf=a;b
85+
// +kubebuilder:validation:ExactlyOneOf=c;d
86+
// +kubebuilder:validation:AtLeastOneOf=e;f
87+
type TypeWithAllOneofs struct {
88+
A *string `json:"a,omitempty"`
89+
B *string `json:"b,omitempty"`
90+
91+
C *string `json:"c,omitempty"`
92+
D *string `json:"d,omitempty"`
93+
94+
E *string `json:"e,omitempty"`
95+
F *string `json:"f,omitempty"`
96+
}
97+
7098
// Oneof is the Schema for the Oneof API
7199
type Oneof struct {
72100
/*

pkg/crd/testdata/testdata.kubebuilder.io_oneofs.yaml

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
apiVersion: apiextensions.k8s.io/v1
22
kind: CustomResourceDefinition
33
metadata:
4-
creationTimestamp: null
54
name: oneofs.testdata.kubebuilder.io
65
spec:
76
group: testdata.kubebuilder.io
@@ -93,6 +92,44 @@ spec:
9392
rule: '[has(self.a),has(self.b)].filter(x,x==true).size() <= 1'
9493
- message: at most one of the fields in [c d] may be set
9594
rule: '[has(self.c),has(self.d)].filter(x,x==true).size() <= 1'
95+
typeWithAllOneOf:
96+
properties:
97+
a:
98+
type: string
99+
b:
100+
type: string
101+
c:
102+
type: string
103+
d:
104+
type: string
105+
e:
106+
type: string
107+
f:
108+
type: string
109+
type: object
110+
x-kubernetes-validations:
111+
- message: at most one of the fields in [a b] may be set
112+
rule: '[has(self.a),has(self.b)].filter(x,x==true).size() <= 1'
113+
- message: exactly one of the fields in [c d] must be set
114+
rule: '[has(self.c),has(self.d)].filter(x,x==true).size() == 1'
115+
- message: at least one of the fields in [e f] must be set
116+
rule: '[has(self.e),has(self.f)].filter(x,x==true).size() >= 1'
117+
typeWithMultipleAtLeastOneOf:
118+
properties:
119+
a:
120+
type: string
121+
b:
122+
type: string
123+
c:
124+
type: string
125+
d:
126+
type: string
127+
type: object
128+
x-kubernetes-validations:
129+
- message: at least one of the fields in [a b] must be set
130+
rule: '[has(self.a),has(self.b)].filter(x,x==true).size() >= 1'
131+
- message: at least one of the fields in [c d] must be set
132+
rule: '[has(self.c),has(self.d)].filter(x,x==true).size() >= 1'
96133
type: object
97134
required:
98135
- spec

pkg/crd/validation_test.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,19 @@ spec:
111111
`,
112112
wantErr: `spec.secondTypeWithExactOneof: Invalid value: "object": exactly one of the fields in [c d] must be set`,
113113
},
114+
{
115+
name: "AtLeastOneOf constraint violated by not specifying field typeWithAllOneOf.e|f",
116+
obj: `---
117+
kind: Oneof
118+
apiVersion: testdata.kubebuilder.io/v1beta1
119+
metadata:
120+
name: test
121+
spec:
122+
typeWithAllOneOf:
123+
c: "c"
124+
`,
125+
wantErr: `spec.typeWithAllOneOf: Invalid value: "object": at least one of the fields in [e f] must be set`,
126+
},
114127
}
115128

116129
validator, err := newValidator(t.Context(), "./testdata/testdata.kubebuilder.io_oneofs.yaml")

0 commit comments

Comments
 (0)