Skip to content
Open
Show file tree
Hide file tree
Changes from 6 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
19 changes: 19 additions & 0 deletions docs/linters.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
- [JSONTags](#jsontags) - Ensures proper JSON tag formatting
- [MaxLength](#maxlength) - Checks for maximum length constraints on strings and arrays
- [NamingConventions](#namingconventions) - Ensures field names adhere to user-defined naming conventions
- [NumericBounds](#numericbounds) - Validates numeric fields have appropriate bounds validation markers
- [NoBools](#nobools) - Prevents usage of boolean types
- [NoDurations](#nodurations) - Prevents usage of duration types
- [NoFloats](#nofloats) - Prevents usage of floating-point types
Expand Down Expand Up @@ -477,6 +478,24 @@ linterConfig:
message: prefer 'colour' over 'color' when referring to colours in field names
```

## NumericBounds

The `numericbounds` linter checks that numeric fields (`int32`, `int64`, `float32`, `float64`) have appropriate bounds validation markers.

According to Kubernetes API conventions, numeric fields should have bounds checking to prevent values that are too small, negative (when not intended), or too large.

This linter ensures that:
- Numeric fields have both `+k8s:minimum` and `+k8s:maximum` markers
- Kubebuilder validation markers (`+kubebuilder:validation:Minimum` and `+kubebuilder:validation:Maximum`) are also supported
- Bounds values are validated:
- int32: within int32 range (±2^31-1)
- int64: within JavaScript-safe range (±2^53-1) per K8s API conventions for JSON compatibility
- float32/float64: marker values are valid (within type ranges)

**Note:** While `+k8s:minimum` is documented in the official Kubernetes declarative validation spec, `+k8s:maximum` is not yet officially documented but is supported by this linter for forward compatibility and consistency.

This linter is **not enabled by default** as it is primarily focused on CRD validation with kubebuilder markers.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is fine for now, I expect we will flip this in the future


## NoBools

The `nobools` linter checks that fields in the API types do not contain a `bool` type.
Expand Down
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ require (
github.com/google/go-cmp v0.7.0
github.com/onsi/ginkgo/v2 v2.23.4
github.com/onsi/gomega v1.38.0
golang.org/x/exp v0.0.0-20240909161429-701f63a606c0
golang.org/x/tools v0.37.0
k8s.io/apimachinery v0.32.3
k8s.io/gengo/v2 v2.0.0-20250922181213-ec3ebc5fd46b
Expand Down
218 changes: 218 additions & 0 deletions pkg/analysis/numericbounds/analyzer.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
/*
Copyright 2025 The Kubernetes Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License 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 numericbounds

import (
"errors"
"fmt"
"go/ast"

"golang.org/x/exp/constraints"
"golang.org/x/tools/go/analysis"
kalerrors "sigs.k8s.io/kube-api-linter/pkg/analysis/errors"
"sigs.k8s.io/kube-api-linter/pkg/analysis/helpers/extractjsontags"
"sigs.k8s.io/kube-api-linter/pkg/analysis/helpers/inspector"
markershelper "sigs.k8s.io/kube-api-linter/pkg/analysis/helpers/markers"
"sigs.k8s.io/kube-api-linter/pkg/analysis/utils"
"sigs.k8s.io/kube-api-linter/pkg/markers"
)

const (
name = "numericbounds"
)

// Type bounds for validation.
const (
maxInt32 = 2147483647 // 2^31 - 1
minInt32 = -2147483648 // -2^31
maxFloat32 = 3.40282346638528859811704183484516925440e+38 // max float32
minFloat32 = -3.40282346638528859811704183484516925440e+38 // min float32
maxFloat64 = 1.797693134862315708145274237317043567981e+308 // max float64
minFloat64 = -1.797693134862315708145274237317043567981e+308 // min float64
)

// JavaScript safe integer bounds for int64 (±2^53-1).
// Per Kubernetes API conventions, int64 fields should use bounds within this range
// to ensure compatibility with JavaScript clients.
const (
maxSafeInt64 = 9007199254740991 // 2^53 - 1 (max safe integer in JS)
minSafeInt64 = -9007199254740991 // -(2^53 - 1) (min safe integer in JS)
)

var errMarkerMissingValue = errors.New("marker value not found")

// Analyzer is the analyzer for the numericbounds package.
// It checks that numeric fields have appropriate bounds validation markers.
var Analyzer = &analysis.Analyzer{
Name: name,
Doc: "Checks that numeric fields (int32, int64, float32, float64) have appropriate minimum and maximum bounds validation markers",
Run: run,
Requires: []*analysis.Analyzer{inspector.Analyzer},
}

func run(pass *analysis.Pass) (any, error) {
inspect, ok := pass.ResultOf[inspector.Analyzer].(inspector.Inspector)
if !ok {
return nil, kalerrors.ErrCouldNotGetInspector
}

inspect.InspectFields(func(field *ast.Field, _ extractjsontags.FieldTagInfo, markersAccess markershelper.Markers, qualifiedFieldName string) {
// Create TypeChecker with closure capturing markersAccess and qualifiedFieldName
typeChecker := utils.NewTypeChecker(func(pass *analysis.Pass, ident *ast.Ident, node ast.Node, _ string) {
checkNumericType(pass, ident, node, markersAccess, qualifiedFieldName)
})

typeChecker.CheckNode(pass, field)
})

return nil, nil //nolint:nilnil
}

//nolint:cyclop
func checkNumericType(pass *analysis.Pass, ident *ast.Ident, node ast.Node, markersAccess markershelper.Markers, qualifiedFieldName string) {
// Only check int32, int64, float32, and float64 types
if ident.Name != "int32" && ident.Name != "int64" && ident.Name != "float32" && ident.Name != "float64" {
return
}

field, ok := node.(*ast.Field)
if !ok {
return
}

fieldMarkers := utils.TypeAwareMarkerCollectionForField(pass, markersAccess, field)

// Check if this is an array/slice field
isSlice := utils.IsArrayTypeOrAlias(pass, field)

// Determine which markers to look for based on whether the field is a slice
minMarkers, maxMarkers := getMarkerNames(isSlice)

// Get minimum and maximum marker values
minimum, minErr := getMarkerNumericValue(fieldMarkers, minMarkers)
maximum, maxErr := getMarkerNumericValue(fieldMarkers, maxMarkers)

// Check if markers are missing
minMissing := errors.Is(minErr, errMarkerMissingValue)
maxMissing := errors.Is(maxErr, errMarkerMissingValue)

// Report any invalid marker values (e.g., non-numeric values)
if minErr != nil && !minMissing {
pass.Reportf(field.Pos(), "%s has an invalid minimum marker: %v", qualifiedFieldName, minErr)
}

if maxErr != nil && !maxMissing {
pass.Reportf(field.Pos(), "%s has an invalid maximum marker: %v", qualifiedFieldName, maxErr)
}

// Report if markers are missing
if minMissing {
pass.Reportf(field.Pos(), "%s is missing minimum bound validation marker", qualifiedFieldName)
}

if maxMissing {
pass.Reportf(field.Pos(), "%s is missing maximum bound validation marker", qualifiedFieldName)
}

// If any markers are missing or invalid, don't continue with bounds checks
if minErr != nil || maxErr != nil {
return
}

// Validate bounds are within the type's valid range
checkBoundsWithinTypeRange(pass, field, qualifiedFieldName, ident.Name, minimum, maximum)
}

// getMarkerNames returns the appropriate minimum and maximum marker names
// based on whether the field is a slice.
// Returns both kubebuilder and k8s declarative validation markers.
func getMarkerNames(isSlice bool) (minMarkers, maxMarkers []string) {
if isSlice {
return []string{markers.KubebuilderItemsMinimumMarker}, []string{markers.KubebuilderItemsMaximumMarker}
}

return []string{markers.KubebuilderMinimumMarker, markers.K8sMinimumMarker}, []string{markers.KubebuilderMaximumMarker, markers.K8sMaximumMarker}
}

// getMarkerNumericValue extracts the numeric value from the first instance of any of the given marker names.
// Checks multiple marker names to support both kubebuilder and k8s declarative validation markers.
// Precedence: Markers checked in the order provided and first valid value found is returned.
// We require a valid numeric value (not just marker presence) for both minimum and maximum markers.
func getMarkerNumericValue(markerSet markershelper.MarkerSet, markerNames []string) (float64, error) {
for _, markerName := range markerNames {
markerList := markerSet.Get(markerName)
if len(markerList) == 0 {
continue
}

// Use the exported utils.GetMarkerNumericValue function to parse the marker value
value, err := utils.GetMarkerNumericValue[float64](markerList[0])
if err != nil {
if errors.Is(err, errMarkerMissingValue) {
continue
}

return 0, fmt.Errorf("error getting marker value: %w", err)
}

return value, nil
}

return 0, errMarkerMissingValue
}

// checkBoundsWithinTypeRange validates that the bounds are within the valid range for the type.
// For int64, enforces JavaScript-safe bounds as per Kubernetes API conventions to ensure
// compatibility with JavaScript clients.
func checkBoundsWithinTypeRange(pass *analysis.Pass, field *ast.Field, prefix, typeName string, minimum, maximum float64) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you were to use generics for these bounds checks, you might be able to avoid int to float to int conversions on the integer bounds checks

I suspect a bounds checking function that takes a boundary, a message, an upper and lower boundary could be leveraged here

switch typeName {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

missing int64 and float64?

case "int32":
checkBoundInRange(pass, field, prefix, minimum, minInt32, maxInt32, "minimum", "int32")
checkBoundInRange(pass, field, prefix, maximum, minInt32, maxInt32, "maximum", "int32")
case "int64":
// K8s API conventions enforce JavaScript-safe bounds for int64 (±2^53-1)
checkBoundInRange(pass, field, prefix, minimum, int64(minSafeInt64), int64(maxSafeInt64), "minimum", "JavaScript-safe int64",
"Consider using a string type to avoid precision loss in JavaScript clients")
checkBoundInRange(pass, field, prefix, maximum, int64(minSafeInt64), int64(maxSafeInt64), "maximum", "JavaScript-safe int64",
"Consider using a string type to avoid precision loss in JavaScript clients")
case "float32":
checkFloatBoundInRange(pass, field, prefix, minimum, minFloat32, maxFloat32, "minimum", "float32")
checkFloatBoundInRange(pass, field, prefix, maximum, minFloat32, maxFloat32, "maximum", "float32")
case "float64":
checkFloatBoundInRange(pass, field, prefix, minimum, minFloat64, maxFloat64, "minimum", "float64")
checkFloatBoundInRange(pass, field, prefix, maximum, minFloat64, maxFloat64, "maximum", "float64")
}
}

// checkBoundInRange checks if an integer bound value is within the valid range.
// Uses generics to avoid type conversions.
func checkBoundInRange[T constraints.Integer](pass *analysis.Pass, field *ast.Field, prefix string, value float64, minBound, maxBound T, boundType, typeName string, extraMsg ...string) {
if value < float64(minBound) || value > float64(maxBound) {
msg := fmt.Sprintf("%s has %s bound %%v that is outside the %s range [%%d, %%d]", prefix, boundType, typeName)
if len(extraMsg) > 0 {
msg += ". " + extraMsg[0]
}

pass.Reportf(field.Pos(), msg, value, minBound, maxBound)
}
}

// checkFloatBoundInRange checks if a float bound value is within the valid range.
func checkFloatBoundInRange[T constraints.Float](pass *analysis.Pass, field *ast.Field, prefix string, value float64, minBound, maxBound T, boundType, typeName string) {
if value < float64(minBound) || value > float64(maxBound) {
pass.Reportf(field.Pos(), "%s has %s bound %v that is outside the valid %s range", prefix, boundType, value, typeName)
}
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Check how we did the GetMarkerNumericValue function implementation to work for both floats and integers, we should unify these using the same pattern (a shared number interface that accepts both floats and integers)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

agreed. Latest commit now has the same pattern 👍🏻 i.e. one function for both int and float bounds

28 changes: 28 additions & 0 deletions pkg/analysis/numericbounds/analyzer_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
/*
Copyright 2025 The Kubernetes Authors.

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License 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 numericbounds_test

import (
"testing"

"golang.org/x/tools/go/analysis/analysistest"
"sigs.k8s.io/kube-api-linter/pkg/analysis/numericbounds"
)

func Test(t *testing.T) {
testdata := analysistest.TestData()
analysistest.Run(t, testdata, numericbounds.Analyzer, "a")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does the Analyzer need to be exported? Or can we use the Initializer?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I saw a similar pattern in few other linters(maxlength and minlength) and so i exported for the test file to directly reference it.

}
44 changes: 44 additions & 0 deletions pkg/analysis/numericbounds/doc.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
/*
Copyright 2025 The Kubernetes Authors.

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License 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.
*/

/*
numericbounds is an analyzer that checks numeric fields have appropriate bounds validation markers.

According to Kubernetes API conventions, numeric fields should have bounds checking to prevent
values that are too small, negative (when not intended), or too large.

The analyzer checks that int32, int64, float32, and float64 fields have both minimum and
maximum bounds markers. It supports both kubebuilder markers (+kubebuilder:validation:Minimum/Maximum)
and k8s declarative validation markers (+k8s:minimum/maximum).

For slices of numeric types, the analyzer checks the element type for items:Minimum and items:Maximum markers.

Type aliases are resolved and checked. Pointer types are unwrapped and validated.

Bounds values are validated to be within the type's range:
- int32: full int32 range (±2^31-1)
- int64: JavaScript-safe range (±2^53-1) per Kubernetes API conventions
- float32/float64: within their respective ranges

For int64 fields, Kubernetes API conventions enforce JavaScript-safe bounds (±2^53-1)
to ensure compatibility with JavaScript clients and prevent precision loss.

For arrays of numeric types, the minimum/maximum of each element can be set using
+kubebuilder:validation:items:Minimum and +kubebuilder:validation:items:Maximum markers.
Alternatively, if the array uses a numeric type alias, the markers can be placed on the
alias type definition itself.
*/
package numericbounds
35 changes: 35 additions & 0 deletions pkg/analysis/numericbounds/initializer.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/*
Copyright 2025 The Kubernetes Authors.

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License 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 numericbounds

import (
"sigs.k8s.io/kube-api-linter/pkg/analysis/initializer"
"sigs.k8s.io/kube-api-linter/pkg/analysis/registry"
)

func init() {
registry.DefaultRegistry().RegisterLinter(Initializer())
}

// Initializer returns the AnalyzerInitializer for this
// Analyzer so that it can be added to the registry.
func Initializer() initializer.AnalyzerInitializer {
return initializer.NewInitializer(
name,
Analyzer,
false,
)
}
Loading