Skip to content

Commit 618e68e

Browse files
committed
Allow floating-point values in validations
1 parent d9ac9f3 commit 618e68e

File tree

6 files changed

+156
-27
lines changed

6 files changed

+156
-27
lines changed

pkg/crd/markers/validation.go

Lines changed: 65 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,9 @@ limitations under the License.
1717
package markers
1818

1919
import (
20-
"fmt"
21-
2220
"encoding/json"
21+
"fmt"
22+
"math"
2323

2424
apiext "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
2525

@@ -37,7 +37,7 @@ const (
3737
// reusable and writing complex validations on slice items.
3838
var ValidationMarkers = mustMakeAllWithPrefix("kubebuilder:validation", markers.DescribesField,
3939

40-
// integer markers
40+
// numeric markers
4141

4242
Maximum(0),
4343
Minimum(0),
@@ -122,11 +122,19 @@ func init() {
122122

123123
// +controllertools:marker:generateHelp:category="CRD validation"
124124
// Maximum specifies the maximum numeric value that this field can have.
125-
type Maximum int
125+
type Maximum float64
126+
127+
func (m Maximum) Value() float64 {
128+
return float64(m)
129+
}
126130

127131
// +controllertools:marker:generateHelp:category="CRD validation"
128-
// Minimum specifies the minimum numeric value that this field can have. Negative integers are supported.
129-
type Minimum int
132+
// Minimum specifies the minimum numeric value that this field can have. Negative numbers are supported.
133+
type Minimum float64
134+
135+
func (m Minimum) Value() float64 {
136+
return float64(m)
137+
}
130138

131139
// +controllertools:marker:generateHelp:category="CRD validation"
132140
// ExclusiveMinimum indicates that the minimum is "up to" but not including that value.
@@ -138,7 +146,11 @@ type ExclusiveMaximum bool
138146

139147
// +controllertools:marker:generateHelp:category="CRD validation"
140148
// MultipleOf specifies that this field must have a numeric value that's a multiple of this one.
141-
type MultipleOf int
149+
type MultipleOf float64
150+
151+
func (m MultipleOf) Value() float64 {
152+
return float64(m)
153+
}
142154

143155
// +controllertools:marker:generateHelp:category="CRD validation"
144156
// MaxLength specifies the maximum length for this string.
@@ -251,41 +263,69 @@ type XIntOrString struct{}
251263
// to be used only as a last resort.
252264
type Schemaless struct{}
253265

266+
func hasNumericType(schema *apiext.JSONSchemaProps) bool {
267+
return schema.Type == "integer" || schema.Type == "number"
268+
}
269+
270+
func isIntegral(value float64) bool {
271+
return value == math.Trunc(value) && !math.IsNaN(value) && !math.IsInf(value, 0)
272+
}
273+
254274
func (m Maximum) ApplyToSchema(schema *apiext.JSONSchemaProps) error {
255-
if schema.Type != "integer" {
256-
return fmt.Errorf("must apply maximum to an integer")
275+
if !hasNumericType(schema) {
276+
return fmt.Errorf("must apply maximum to a numeric value, found %s", schema.Type)
277+
}
278+
279+
if schema.Type == "integer" && !isIntegral(m.Value()) {
280+
return fmt.Errorf("cannot apply non-integral maximum validation (%v) to integer value", m.Value())
257281
}
258-
val := float64(m)
282+
283+
val := m.Value()
259284
schema.Maximum = &val
260285
return nil
261286
}
287+
262288
func (m Minimum) ApplyToSchema(schema *apiext.JSONSchemaProps) error {
263-
if schema.Type != "integer" {
264-
return fmt.Errorf("must apply minimum to an integer")
289+
if !hasNumericType(schema) {
290+
return fmt.Errorf("must apply minimum to a numeric value, found %s", schema.Type)
291+
}
292+
293+
if schema.Type == "integer" && !isIntegral(m.Value()) {
294+
return fmt.Errorf("cannot apply non-integral minimum validation (%v) to integer value", m.Value())
265295
}
266-
val := float64(m)
296+
297+
val := m.Value()
267298
schema.Minimum = &val
268299
return nil
269300
}
301+
270302
func (m ExclusiveMaximum) ApplyToSchema(schema *apiext.JSONSchemaProps) error {
271-
if schema.Type != "integer" {
272-
return fmt.Errorf("must apply exclusivemaximum to an integer")
303+
if !hasNumericType(schema) {
304+
return fmt.Errorf("must apply exclusivemaximum to a numeric value, found %s", schema.Type)
273305
}
274306
schema.ExclusiveMaximum = bool(m)
275307
return nil
276308
}
309+
277310
func (m ExclusiveMinimum) ApplyToSchema(schema *apiext.JSONSchemaProps) error {
278-
if schema.Type != "integer" {
279-
return fmt.Errorf("must apply exclusiveminimum to an integer")
311+
if !hasNumericType(schema) {
312+
return fmt.Errorf("must apply exclusiveminimum to a numeric value, found %s", schema.Type)
280313
}
314+
281315
schema.ExclusiveMinimum = bool(m)
282316
return nil
283317
}
318+
284319
func (m MultipleOf) ApplyToSchema(schema *apiext.JSONSchemaProps) error {
285-
if schema.Type != "integer" {
286-
return fmt.Errorf("must apply multipleof to an integer")
320+
if !hasNumericType(schema) {
321+
return fmt.Errorf("must apply multipleof to a numeric value, found %s", schema.Type)
287322
}
288-
val := float64(m)
323+
324+
if schema.Type == "integer" && !isIntegral(m.Value()) {
325+
return fmt.Errorf("cannot apply non-integral multipleof validation (%v) to integer value", m.Value())
326+
}
327+
328+
val := m.Value()
289329
schema.MultipleOf = &val
290330
return nil
291331
}
@@ -298,6 +338,7 @@ func (m MaxLength) ApplyToSchema(schema *apiext.JSONSchemaProps) error {
298338
schema.MaxLength = &val
299339
return nil
300340
}
341+
301342
func (m MinLength) ApplyToSchema(schema *apiext.JSONSchemaProps) error {
302343
if schema.Type != "string" {
303344
return fmt.Errorf("must apply minlength to a string")
@@ -306,6 +347,7 @@ func (m MinLength) ApplyToSchema(schema *apiext.JSONSchemaProps) error {
306347
schema.MinLength = &val
307348
return nil
308349
}
350+
309351
func (m Pattern) ApplyToSchema(schema *apiext.JSONSchemaProps) error {
310352
// Allow string types or IntOrStrings. An IntOrString will still
311353
// apply the pattern validation when a string is detected, the pattern
@@ -325,6 +367,7 @@ func (m MaxItems) ApplyToSchema(schema *apiext.JSONSchemaProps) error {
325367
schema.MaxItems = &val
326368
return nil
327369
}
370+
328371
func (m MinItems) ApplyToSchema(schema *apiext.JSONSchemaProps) error {
329372
if schema.Type != "array" {
330373
return fmt.Errorf("must apply minitems to an array")
@@ -333,6 +376,7 @@ func (m MinItems) ApplyToSchema(schema *apiext.JSONSchemaProps) error {
333376
schema.MinItems = &val
334377
return nil
335378
}
379+
336380
func (m UniqueItems) ApplyToSchema(schema *apiext.JSONSchemaProps) error {
337381
if schema.Type != "array" {
338382
return fmt.Errorf("must apply uniqueitems to an array")
@@ -376,6 +420,7 @@ func (m Enum) ApplyToSchema(schema *apiext.JSONSchemaProps) error {
376420
schema.Enum = vals
377421
return nil
378422
}
423+
379424
func (m Format) ApplyToSchema(schema *apiext.JSONSchemaProps) error {
380425
schema.Format = string(m)
381426
return nil

pkg/crd/markers/zz_generated.markerhelp.go

Lines changed: 1 addition & 1 deletion
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: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -72,8 +72,9 @@ var _ = Describe("CRD Generation From Parsing to CustomResourceDefinition", func
7272
reg := &markers.Registry{}
7373
Expect(crdmarkers.Register(reg)).To(Succeed())
7474
parser := &crd.Parser{
75-
Collector: &markers.Collector{Registry: reg},
76-
Checker: &loader.TypeChecker{},
75+
Collector: &markers.Collector{Registry: reg},
76+
Checker: &loader.TypeChecker{},
77+
AllowDangerousTypes: true, // need to allow “dangerous types” in this file for testing
7778
}
7879
crd.AddKnownTypes(parser)
7980

@@ -188,6 +189,5 @@ var _ = Describe("CRD Generation From Parsing to CustomResourceDefinition", func
188189

189190
By("checking that no errors occurred along the way (expect for type errors)")
190191
Expect(packageErrors(cronJobPkg, packages.TypeError)).NotTo(HaveOccurred())
191-
192192
})
193193
})

pkg/crd/testdata/cronjob_types.go

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ limitations under the License.
1616
// TODO(directxman12): test this across both versions (right now we're just
1717
// trusting k/k conversion, which is probably fine though)
1818

19-
//go:generate ../../../.run-controller-gen.sh crd paths=./;./deprecated;./unserved output:dir=.
19+
//go:generate ../../../.run-controller-gen.sh crd:allowDangerousTypes=true paths=./;./deprecated;./unserved output:dir=.
2020

2121
// +groupName=testdata.kubebuilder.io
2222
// +versionName=v1
@@ -181,6 +181,26 @@ type CronJobSpec struct {
181181

182182
// Maps of arrays of things-that-aren’t-strings are permitted
183183
MapOfArraysOfFloats map[string][]bool `json:"mapOfArraysOfFloats,omitempty"`
184+
185+
// +kubebuilder:validation:Minimum=-0.5
186+
// +kubebuilder:validation:Maximum=1.5
187+
// +kubebuilder:validation:MultipleOf=0.5
188+
FloatWithValidations float64 `json:"floatWithValidations"`
189+
190+
// +kubebuilder:validation:Minimum=-0.5
191+
// +kubebuilder:validation:Maximum=1.5
192+
// +kubebuilder:validation:MultipleOf=0.5
193+
Float64WithValidations float64 `json:"float64WithValidations"`
194+
195+
// +kubebuilder:validation:Minimum=-2
196+
// +kubebuilder:validation:Maximum=2
197+
// +kubebuilder:validation:MultipleOf=2
198+
IntWithValidations int `json:"intWithValidations"`
199+
200+
// +kubebuilder:validation:Minimum=-2
201+
// +kubebuilder:validation:Maximum=2
202+
// +kubebuilder:validation:MultipleOf=2
203+
Int32WithValidations int32 `json:"int32WithValidations"`
184204
}
185205

186206
type ContainsNestedMap struct {

pkg/crd/testdata/testdata.kubebuilder.io_cronjobs.yaml

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,22 @@ spec:
125125
a pointer to distinguish between explicit zero and not specified.
126126
format: int32
127127
type: integer
128+
float64WithValidations:
129+
maximum: 1.5
130+
minimum: -0.5
131+
multipleOf: 0.5
132+
type: number
133+
floatWithValidations:
134+
maximum: 1.5
135+
minimum: -0.5
136+
multipleOf: 0.5
137+
type: number
138+
int32WithValidations:
139+
format: int32
140+
maximum: 2
141+
minimum: -2
142+
multipleOf: 2
143+
type: integer
128144
intOrStringWithAPattern:
129145
anyOf:
130146
- type: integer
@@ -135,6 +151,11 @@ spec:
135151
for having a pattern on this type.
136152
pattern: ^((100|[0-9]{1,2})%|[0-9]+)$
137153
x-kubernetes-int-or-string: true
154+
intWithValidations:
155+
maximum: 2
156+
minimum: -2
157+
multipleOf: 2
158+
type: integer
138159
jobTemplate:
139160
description: Specifies the job that will be created when executing
140161
a CronJob.
@@ -7319,6 +7340,10 @@ spec:
73197340
- defaultedSlice
73207341
- defaultedString
73217342
- embeddedResource
7343+
- float64WithValidations
7344+
- floatWithValidations
7345+
- int32WithValidations
7346+
- intWithValidations
73227347
- jobTemplate
73237348
- mapOfInfo
73247349
- patternObject

pkg/markers/parse.go

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,8 @@ const (
8484
InvalidType ArgumentType = iota
8585
// IntType is an int
8686
IntType
87+
// NumberType is a float64
88+
NumberType
8789
// StringType is a string
8890
StringType
8991
// BoolType is a bool
@@ -127,6 +129,8 @@ func (a Argument) typeString(out *strings.Builder) {
127129
out.WriteString("<invalid>")
128130
case IntType:
129131
out.WriteString("int")
132+
case NumberType:
133+
out.WriteString("float64")
130134
case StringType:
131135
out.WriteString("string")
132136
case BoolType:
@@ -180,6 +184,8 @@ func makeSliceType(itemType Argument) (reflect.Type, error) {
180184
switch itemType.Type {
181185
case IntType:
182186
itemReflectedType = reflect.TypeOf(int(0))
187+
case NumberType:
188+
itemReflectedType = reflect.TypeOf(float64(0))
183189
case StringType:
184190
itemReflectedType = reflect.TypeOf("")
185191
case BoolType:
@@ -215,6 +221,8 @@ func makeMapType(itemType Argument) (reflect.Type, error) {
215221
switch itemType.Type {
216222
case IntType:
217223
itemReflectedType = reflect.TypeOf(int(0))
224+
case NumberType:
225+
itemReflectedType = reflect.TypeOf(float64(0))
218226
case StringType:
219227
itemReflectedType = reflect.TypeOf("")
220228
case BoolType:
@@ -346,8 +354,11 @@ func guessType(scanner *sc.Scanner, raw string, allowSlice bool) *Argument {
346354
if nextTok == '-' {
347355
nextTok = subScanner.Scan()
348356
}
357+
349358
if nextTok == sc.Int {
350359
return &Argument{Type: IntType}
360+
} else if nextTok == sc.Float {
361+
return &Argument{Type: NumberType}
351362
}
352363
}
353364

@@ -471,7 +482,7 @@ func (a *Argument) parseMap(scanner *sc.Scanner, raw string, out reflect.Value)
471482
func (a *Argument) parse(scanner *sc.Scanner, raw string, out reflect.Value, inSlice bool) {
472483
// nolint:gocyclo
473484
if a.Type == InvalidType {
474-
scanner.Error(scanner, fmt.Sprintf("cannot parse invalid type"))
485+
scanner.Error(scanner, "cannot parse invalid type")
475486
return
476487
}
477488
if a.Pointer {
@@ -485,6 +496,32 @@ func (a *Argument) parse(scanner *sc.Scanner, raw string, out reflect.Value, inS
485496
// consume everything else
486497
for tok := scanner.Scan(); tok != sc.EOF; tok = scanner.Scan() {
487498
}
499+
case NumberType:
500+
nextChar := scanner.Peek()
501+
isNegative := false
502+
if nextChar == '-' {
503+
isNegative = true
504+
scanner.Scan() // eat the '-'
505+
}
506+
507+
tok := scanner.Scan()
508+
if tok != sc.Float && tok != sc.Int {
509+
scanner.Error(scanner, fmt.Sprintf("expected integer or float, got %q", scanner.TokenText()))
510+
return
511+
}
512+
513+
text := scanner.TokenText()
514+
if isNegative {
515+
text = "-" + text
516+
}
517+
518+
val, err := strconv.ParseFloat(text, 64)
519+
if err != nil {
520+
scanner.Error(scanner, fmt.Sprintf("unable to parse number: %v", err))
521+
return
522+
}
523+
524+
castAndSet(out, reflect.ValueOf(val))
488525
case IntType:
489526
nextChar := scanner.Peek()
490527
isNegative := false
@@ -597,6 +634,8 @@ func ArgumentFromType(rawType reflect.Type) (Argument, error) {
597634
arg.Type = StringType
598635
case reflect.Int, reflect.Int32: // NB(directxman12): all ints in kubernetes are int32, so explicitly support that
599636
arg.Type = IntType
637+
case reflect.Float64:
638+
arg.Type = NumberType
600639
case reflect.Bool:
601640
arg.Type = BoolType
602641
case reflect.Slice:
@@ -755,7 +794,7 @@ func (d *Definition) loadFields() error {
755794
func parserScanner(raw string, err func(*sc.Scanner, string)) *sc.Scanner {
756795
scanner := &sc.Scanner{}
757796
scanner.Init(bytes.NewBufferString(raw))
758-
scanner.Mode = sc.ScanIdents | sc.ScanInts | sc.ScanStrings | sc.ScanRawStrings | sc.SkipComments
797+
scanner.Mode = sc.ScanIdents | sc.ScanInts | sc.ScanStrings | sc.ScanRawStrings | sc.SkipComments | sc.ScanFloats
759798
scanner.Error = err
760799

761800
return scanner

0 commit comments

Comments
 (0)