Skip to content

Add statusoptional linter #73

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
May 14, 2025
Merged
Show file tree
Hide file tree
Changes from all 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
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,20 @@ It will suggest to remove the pointer from the field, and update the `json` tag
If you prefer not to suggest fixes for pointers in required fields, you can change the `pointerPolicy` to `Warn`.
The linter will then only suggest to remove the `omitempty` value from the `json` tag.

## StatusOptional

The `statusoptional` linter checks that all first-level children fields within a status struct are marked as optional.

This is important because status fields should be optional to allow for partial updates and backward compatibility.
The linter ensures that all direct child fields of any status struct have either the `// +optional` or
`// +kubebuilder:validation:Optional` marker.

### Fixes

The `statusoptional` linter can automatically fix fields in status structs that are not marked as optional.

It will suggest adding the `// +optional` marker to any status field that is missing it.

## StatusSubresource

The `statussubresource` linter checks that the status subresource is configured correctly for
Expand Down
2 changes: 0 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,6 @@ github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
github.com/golangci/golangci-lint/v2 v2.0.0 h1:RQWk8VCuMQv9bBDy3x23yds2yf9aRZ86C9MWGIdNRuU=
github.com/golangci/golangci-lint/v2 v2.0.0/go.mod h1:ptNNMeGBQrbves0Qq38xvfdJg18PzxmT+7KRCOpm6i8=
github.com/golangci/plugin-module-register v0.1.1 h1:TCmesur25LnyJkpsVrupv1Cdzo+2f7zX0H6Jkw1Ol6c=
github.com/golangci/plugin-module-register v0.1.1/go.mod h1:TTpqoB6KkwOJMV8u7+NyXMrkwwESJLOkfl9TxR1DGFc=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
Expand Down
2 changes: 2 additions & 0 deletions pkg/analysis/registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import (
"sigs.k8s.io/kube-api-linter/pkg/analysis/nophase"
"sigs.k8s.io/kube-api-linter/pkg/analysis/optionalorrequired"
"sigs.k8s.io/kube-api-linter/pkg/analysis/requiredfields"
"sigs.k8s.io/kube-api-linter/pkg/analysis/statusoptional"
"sigs.k8s.io/kube-api-linter/pkg/analysis/statussubresource"
"sigs.k8s.io/kube-api-linter/pkg/config"

Expand Down Expand Up @@ -83,6 +84,7 @@ func NewRegistry() Registry {
nophase.Initializer(),
optionalorrequired.Initializer(),
requiredfields.Initializer(),
statusoptional.Initializer(),
statussubresource.Initializer(),
},
}
Expand Down
2 changes: 2 additions & 0 deletions pkg/analysis/registry_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ var _ = Describe("Registry", func() {
"nophase",
"optionalorrequired",
"requiredfields",
"statusoptional",
))
})
})
Expand All @@ -59,6 +60,7 @@ var _ = Describe("Registry", func() {
"nophase",
"optionalorrequired",
"requiredfields",
"statusoptional",
"statussubresource",
))
})
Expand Down
260 changes: 260 additions & 0 deletions pkg/analysis/statusoptional/analyzer.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,260 @@
/*
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 statusoptional

import (
"fmt"
"go/ast"

"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 = "statusoptional"

statusJSONTag = "status"
)

func init() {
markershelper.DefaultRegistry().Register(
markers.OptionalMarker,
markers.KubebuilderOptionalMarker,
markers.K8sOptionalMarker,
markers.RequiredMarker,
markers.KubebuilderRequiredMarker,
markers.K8sRequiredMarker,
)
}

type analyzer struct {
preferredOptionalMarker string
}

// newAnalyzer creates a new analyzer.
func newAnalyzer(preferredOptionalMarker string) *analysis.Analyzer {
if preferredOptionalMarker == "" {
preferredOptionalMarker = markers.OptionalMarker
}

a := &analyzer{
preferredOptionalMarker: preferredOptionalMarker,
}

return &analysis.Analyzer{
Name: name,
Doc: "Checks that all first-level children fields within status struct are marked as optional",
Run: a.run,
Requires: []*analysis.Analyzer{inspector.Analyzer, extractjsontags.Analyzer},
}
}

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

jsonTags, ok := pass.ResultOf[extractjsontags.Analyzer].(extractjsontags.StructFieldTags)
if !ok {
return nil, kalerrors.ErrCouldNotGetJSONTags
}

inspect.InspectFields(func(field *ast.Field, stack []ast.Node, jsonTagInfo extractjsontags.FieldTagInfo, markersAccess markershelper.Markers) {
if jsonTagInfo.Name != statusJSONTag {
return
}

statusStructType := getStructFromField(field)
if statusStructType == nil {
return
}

a.checkStatusStruct(pass, statusStructType, markersAccess, jsonTags)
})

return nil, nil //nolint:nilnil
}

func (a *analyzer) checkStatusStruct(pass *analysis.Pass, statusType *ast.StructType, markersAccess markershelper.Markers, jsonTags extractjsontags.StructFieldTags) {
if statusType.Fields == nil || statusType.Fields.List == nil {
return
}

// Check each child field of the status struct
for _, childField := range statusType.Fields.List {
fieldName := utils.FieldName(childField)
jsonTagInfo := jsonTags.FieldTags(childField)

switch {
case fieldName == "", jsonTagInfo.Ignored:
// Skip fields that are ignored or have no name
case jsonTagInfo.Inline:
if len(childField.Names) > 0 {
// Inline fields should not have names
continue
}
// Check embedded structs recursively
a.checkStatusStruct(pass, getStructFromField(childField), markersAccess, jsonTags)
default:
// Check if the field has the required optional markers
a.checkFieldOptionalMarker(pass, childField, fieldName, markersAccess)
}
}
}

// checkFieldOptionalMarker checks if a field has the required optional markers.
// If the field has a required marker, it will be replaced with the preferred optional marker.
// If the field does not have an optional marker, it will be added.
func (a *analyzer) checkFieldOptionalMarker(pass *analysis.Pass, field *ast.Field, fieldName string, markersAccess markershelper.Markers) {
fieldMarkers := markersAccess.FieldMarkers(field)

// Check if the field has either the optional or kubebuilder:validation:Optional marker
if hasOptionalMarker(fieldMarkers) {
return
}

// Check if the field has required markers that need to be replaced
if hasRequiredMarker(fieldMarkers) {
a.reportAndReplaceRequiredMarkers(pass, field, fieldName, fieldMarkers)
} else {
// Report the error and suggest a fix to add the optional marker
a.reportAndAddOptionalMarker(pass, field, fieldName)
}
}

// hasOptionalMarker checks if a field has any optional marker.
func hasOptionalMarker(fieldMarkers markershelper.MarkerSet) bool {
return fieldMarkers.Has(markers.OptionalMarker) ||
fieldMarkers.Has(markers.KubebuilderOptionalMarker) ||
fieldMarkers.Has(markers.K8sOptionalMarker)
}

// hasRequiredMarker checks if a field has any required marker.
func hasRequiredMarker(fieldMarkers markershelper.MarkerSet) bool {
return fieldMarkers.Has(markers.RequiredMarker) ||
fieldMarkers.Has(markers.KubebuilderRequiredMarker) ||
fieldMarkers.Has(markers.K8sRequiredMarker)
}

// reportAndReplaceRequiredMarkers reports an error and suggests replacing required markers with optional ones.
func (a *analyzer) reportAndReplaceRequiredMarkers(pass *analysis.Pass, field *ast.Field, fieldName string, fieldMarkers markershelper.MarkerSet) {
textEdits := createMarkerRemovalEdits(fieldMarkers)

// Add the preferred optional marker at the beginning of the field
textEdits = append(textEdits, analysis.TextEdit{
Pos: field.Pos(),
NewText: fmt.Appendf(nil, "// +%s\n", a.preferredOptionalMarker),
})

pass.Report(analysis.Diagnostic{
Pos: field.Pos(),
Message: fmt.Sprintf("status field %q must be marked as optional, not required", fieldName),
SuggestedFixes: []analysis.SuggestedFix{{
Message: fmt.Sprintf("replace required marker(s) with %s", a.preferredOptionalMarker),
TextEdits: textEdits,
}},
})
}

// reportAndAddOptionalMarker reports an error and suggests adding an optional marker.
// TODO: consolidate the logic for removing markers with other linters.
func (a *analyzer) reportAndAddOptionalMarker(pass *analysis.Pass, field *ast.Field, fieldName string) {
pass.Report(analysis.Diagnostic{
Pos: field.Pos(),
Message: fmt.Sprintf("status field %q must be marked as optional", fieldName),
SuggestedFixes: []analysis.SuggestedFix{
{
Message: "add the optional marker",
TextEdits: []analysis.TextEdit{
{
// Position at the beginning of the line of the field
Pos: field.Pos(),
// Insert the marker before the field
NewText: fmt.Appendf(nil, "// +%s\n", a.preferredOptionalMarker),
},
},
},
},
})
}

// createMarkerRemovalEdits creates text edits to remove required markers.
// TODO: consolidate the logic for removing markers with other linters.
func createMarkerRemovalEdits(fieldMarkers markershelper.MarkerSet) []analysis.TextEdit {
var textEdits []analysis.TextEdit

// Handle standard required markers
if fieldMarkers.Has(markers.RequiredMarker) {
for _, marker := range fieldMarkers[markers.RequiredMarker] {
textEdits = append(textEdits, analysis.TextEdit{
Pos: marker.Pos,
End: marker.End + 1,
NewText: []byte(""),
})
}
}

// Handle kubebuilder required markers
if fieldMarkers.Has(markers.KubebuilderRequiredMarker) {
for _, marker := range fieldMarkers[markers.KubebuilderRequiredMarker] {
textEdits = append(textEdits, analysis.TextEdit{
Pos: marker.Pos,
End: marker.End + 1,
NewText: []byte(""),
})
}
}

// Handle k8s required markers
if fieldMarkers.Has(markers.K8sRequiredMarker) {
for _, marker := range fieldMarkers[markers.K8sRequiredMarker] {
textEdits = append(textEdits, analysis.TextEdit{
Pos: marker.Pos,
End: marker.End + 1,
NewText: []byte(""),
})
}
}

return textEdits
}

// getStructFromField extracts the struct type from an AST Field.
func getStructFromField(field *ast.Field) *ast.StructType {
ident, ok := field.Type.(*ast.Ident)
if !ok {
return nil
}

typeSpec, ok := ident.Obj.Decl.(*ast.TypeSpec)
if !ok {
return nil
}

structType, ok := typeSpec.Type.(*ast.StructType)
if !ok {
return nil
}

return structType
}
38 changes: 38 additions & 0 deletions pkg/analysis/statusoptional/analyzer_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
/*
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 statusoptional

import (
"testing"

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

func Test(t *testing.T) {
testdata := analysistest.TestData()
analysistest.RunWithSuggestedFixes(t, testdata, newAnalyzer(markers.OptionalMarker), "a")
}

func TestWithKubebuilderOptionalMarker(t *testing.T) {
testdata := analysistest.TestData()
analysistest.RunWithSuggestedFixes(t, testdata, newAnalyzer(markers.KubebuilderOptionalMarker), "b")
}

func TestWithK8sOptionalMarker(t *testing.T) {
testdata := analysistest.TestData()
analysistest.RunWithSuggestedFixes(t, testdata, newAnalyzer(markers.K8sOptionalMarker), "c")
}
32 changes: 32 additions & 0 deletions pkg/analysis/statusoptional/doc.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
/*
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.
*/

/*
The statusoptional linter ensures that all first-level children fields within a status struct
are marked as optional.

This is important because status fields should be optional to allow for partial updates
and backward compatibility.

This linter checks:
1. For structs with a JSON tag of "status"
2. All direct child fields of the status struct
3. Ensures each child field has an optional marker

The linter will report an issue if any field in the status struct is not marked as optional
and will suggest a fix to add the appropriate optional marker.
*/
package statusoptional
Loading