Skip to content

Commit 50f7599

Browse files
committed
Add statusoptional linter
This commit introduces a new linter, `statusoptional`, which checks that all first-level children fields within a status struct are marked as optional. It adds functionality to automatically suggest fixes for fields that are missing the appropriate markers.
1 parent 00a5c0f commit 50f7599

17 files changed

+931
-2
lines changed

README.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -310,6 +310,20 @@ It will suggest to remove the pointer from the field, and update the `json` tag
310310
If you prefer not to suggest fixes for pointers in required fields, you can change the `pointerPolicy` to `Warn`.
311311
The linter will then only suggest to remove the `omitempty` value from the `json` tag.
312312

313+
## StatusOptional
314+
315+
The `statusoptional` linter checks that all first-level children fields within a status struct are marked as optional.
316+
317+
This is important because status fields should be optional to allow for partial updates and backward compatibility.
318+
The linter ensures that all direct child fields of any status struct have either the `// +optional` or
319+
`// +kubebuilder:validation:Optional` marker.
320+
321+
### Fixes
322+
323+
The `statusoptional` linter can automatically fix fields in status structs that are not marked as optional.
324+
325+
It will suggest adding the `// +optional` marker to any status field that is missing it.
326+
313327
## StatusSubresource
314328

315329
The `statussubresource` linter checks that the status subresource is configured correctly for

go.sum

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,6 @@ github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
55
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
66
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
77
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
8-
github.com/golangci/golangci-lint/v2 v2.0.0 h1:RQWk8VCuMQv9bBDy3x23yds2yf9aRZ86C9MWGIdNRuU=
9-
github.com/golangci/golangci-lint/v2 v2.0.0/go.mod h1:ptNNMeGBQrbves0Qq38xvfdJg18PzxmT+7KRCOpm6i8=
108
github.com/golangci/plugin-module-register v0.1.1 h1:TCmesur25LnyJkpsVrupv1Cdzo+2f7zX0H6Jkw1Ol6c=
119
github.com/golangci/plugin-module-register v0.1.1/go.mod h1:TTpqoB6KkwOJMV8u7+NyXMrkwwESJLOkfl9TxR1DGFc=
1210
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=

pkg/analysis/registry.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import (
3030
"sigs.k8s.io/kube-api-linter/pkg/analysis/nophase"
3131
"sigs.k8s.io/kube-api-linter/pkg/analysis/optionalorrequired"
3232
"sigs.k8s.io/kube-api-linter/pkg/analysis/requiredfields"
33+
"sigs.k8s.io/kube-api-linter/pkg/analysis/statusoptional"
3334
"sigs.k8s.io/kube-api-linter/pkg/analysis/statussubresource"
3435
"sigs.k8s.io/kube-api-linter/pkg/config"
3536

@@ -83,6 +84,7 @@ func NewRegistry() Registry {
8384
nophase.Initializer(),
8485
optionalorrequired.Initializer(),
8586
requiredfields.Initializer(),
87+
statusoptional.Initializer(),
8688
statussubresource.Initializer(),
8789
},
8890
}

pkg/analysis/registry_test.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ var _ = Describe("Registry", func() {
4040
"nophase",
4141
"optionalorrequired",
4242
"requiredfields",
43+
"statusoptional",
4344
))
4445
})
4546
})
@@ -59,6 +60,7 @@ var _ = Describe("Registry", func() {
5960
"nophase",
6061
"optionalorrequired",
6162
"requiredfields",
63+
"statusoptional",
6264
"statussubresource",
6365
))
6466
})
Lines changed: 260 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,260 @@
1+
/*
2+
Copyright 2025 The Kubernetes Authors.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
package statusoptional
17+
18+
import (
19+
"fmt"
20+
"go/ast"
21+
22+
"golang.org/x/tools/go/analysis"
23+
24+
kalerrors "sigs.k8s.io/kube-api-linter/pkg/analysis/errors"
25+
"sigs.k8s.io/kube-api-linter/pkg/analysis/helpers/extractjsontags"
26+
"sigs.k8s.io/kube-api-linter/pkg/analysis/helpers/inspector"
27+
markershelper "sigs.k8s.io/kube-api-linter/pkg/analysis/helpers/markers"
28+
"sigs.k8s.io/kube-api-linter/pkg/analysis/utils"
29+
"sigs.k8s.io/kube-api-linter/pkg/markers"
30+
)
31+
32+
const (
33+
name = "statusoptional"
34+
35+
statusJSONTag = "status"
36+
)
37+
38+
func init() {
39+
markershelper.DefaultRegistry().Register(
40+
markers.OptionalMarker,
41+
markers.KubebuilderOptionalMarker,
42+
markers.K8sOptionalMarker,
43+
markers.RequiredMarker,
44+
markers.KubebuilderRequiredMarker,
45+
markers.K8sRequiredMarker,
46+
)
47+
}
48+
49+
type analyzer struct {
50+
preferredOptionalMarker string
51+
}
52+
53+
// newAnalyzer creates a new analyzer.
54+
func newAnalyzer(preferredOptionalMarker string) *analysis.Analyzer {
55+
if preferredOptionalMarker == "" {
56+
preferredOptionalMarker = markers.OptionalMarker
57+
}
58+
59+
a := &analyzer{
60+
preferredOptionalMarker: preferredOptionalMarker,
61+
}
62+
63+
return &analysis.Analyzer{
64+
Name: name,
65+
Doc: "Checks that all first-level children fields within status struct are marked as optional",
66+
Run: a.run,
67+
Requires: []*analysis.Analyzer{inspector.Analyzer, extractjsontags.Analyzer},
68+
}
69+
}
70+
71+
func (a *analyzer) run(pass *analysis.Pass) (any, error) {
72+
inspect, ok := pass.ResultOf[inspector.Analyzer].(inspector.Inspector)
73+
if !ok {
74+
return nil, kalerrors.ErrCouldNotGetInspector
75+
}
76+
77+
jsonTags, ok := pass.ResultOf[extractjsontags.Analyzer].(extractjsontags.StructFieldTags)
78+
if !ok {
79+
return nil, kalerrors.ErrCouldNotGetJSONTags
80+
}
81+
82+
inspect.InspectFields(func(field *ast.Field, stack []ast.Node, jsonTagInfo extractjsontags.FieldTagInfo, markersAccess markershelper.Markers) {
83+
if jsonTagInfo.Name != statusJSONTag {
84+
return
85+
}
86+
87+
statusStructType := getStructFromField(field)
88+
if statusStructType == nil {
89+
return
90+
}
91+
92+
a.checkStatusStruct(pass, statusStructType, markersAccess, jsonTags)
93+
})
94+
95+
return nil, nil //nolint:nilnil
96+
}
97+
98+
func (a *analyzer) checkStatusStruct(pass *analysis.Pass, statusType *ast.StructType, markersAccess markershelper.Markers, jsonTags extractjsontags.StructFieldTags) {
99+
if statusType.Fields == nil || statusType.Fields.List == nil {
100+
return
101+
}
102+
103+
// Check each child field of the status struct
104+
for _, childField := range statusType.Fields.List {
105+
fieldName := utils.FieldName(childField)
106+
jsonTagInfo := jsonTags.FieldTags(childField)
107+
108+
switch {
109+
case fieldName == "", jsonTagInfo.Ignored:
110+
// Skip fields that are ignored or have no name
111+
case jsonTagInfo.Inline:
112+
if len(childField.Names) > 0 {
113+
// Inline fields should not have names
114+
continue
115+
}
116+
// Check embedded structs recursively
117+
a.checkStatusStruct(pass, getStructFromField(childField), markersAccess, jsonTags)
118+
default:
119+
// Check if the field has the required optional markers
120+
a.checkFieldOptionalMarker(pass, childField, fieldName, markersAccess)
121+
}
122+
}
123+
}
124+
125+
// checkFieldOptionalMarker checks if a field has the required optional markers.
126+
// If the field has a required marker, it will be replaced with the preferred optional marker.
127+
// If the field does not have an optional marker, it will be added.
128+
func (a *analyzer) checkFieldOptionalMarker(pass *analysis.Pass, field *ast.Field, fieldName string, markersAccess markershelper.Markers) {
129+
fieldMarkers := markersAccess.FieldMarkers(field)
130+
131+
// Check if the field has either the optional or kubebuilder:validation:Optional marker
132+
if hasOptionalMarker(fieldMarkers) {
133+
return
134+
}
135+
136+
// Check if the field has required markers that need to be replaced
137+
if hasRequiredMarker(fieldMarkers) {
138+
a.reportAndReplaceRequiredMarkers(pass, field, fieldName, fieldMarkers)
139+
} else {
140+
// Report the error and suggest a fix to add the optional marker
141+
a.reportAndAddOptionalMarker(pass, field, fieldName)
142+
}
143+
}
144+
145+
// hasOptionalMarker checks if a field has any optional marker.
146+
func hasOptionalMarker(fieldMarkers markershelper.MarkerSet) bool {
147+
return fieldMarkers.Has(markers.OptionalMarker) ||
148+
fieldMarkers.Has(markers.KubebuilderOptionalMarker) ||
149+
fieldMarkers.Has(markers.K8sOptionalMarker)
150+
}
151+
152+
// hasRequiredMarker checks if a field has any required marker.
153+
func hasRequiredMarker(fieldMarkers markershelper.MarkerSet) bool {
154+
return fieldMarkers.Has(markers.RequiredMarker) ||
155+
fieldMarkers.Has(markers.KubebuilderRequiredMarker) ||
156+
fieldMarkers.Has(markers.K8sRequiredMarker)
157+
}
158+
159+
// reportAndReplaceRequiredMarkers reports an error and suggests replacing required markers with optional ones.
160+
func (a *analyzer) reportAndReplaceRequiredMarkers(pass *analysis.Pass, field *ast.Field, fieldName string, fieldMarkers markershelper.MarkerSet) {
161+
textEdits := createMarkerRemovalEdits(fieldMarkers)
162+
163+
// Add the preferred optional marker at the beginning of the field
164+
textEdits = append(textEdits, analysis.TextEdit{
165+
Pos: field.Pos(),
166+
NewText: fmt.Appendf(nil, "// +%s\n", a.preferredOptionalMarker),
167+
})
168+
169+
pass.Report(analysis.Diagnostic{
170+
Pos: field.Pos(),
171+
Message: fmt.Sprintf("status field %q must be marked as optional, not required", fieldName),
172+
SuggestedFixes: []analysis.SuggestedFix{{
173+
Message: fmt.Sprintf("replace required marker(s) with %s", a.preferredOptionalMarker),
174+
TextEdits: textEdits,
175+
}},
176+
})
177+
}
178+
179+
// reportAndAddOptionalMarker reports an error and suggests adding an optional marker.
180+
// TODO: consolidate the logic for removing markers with other linters.
181+
func (a *analyzer) reportAndAddOptionalMarker(pass *analysis.Pass, field *ast.Field, fieldName string) {
182+
pass.Report(analysis.Diagnostic{
183+
Pos: field.Pos(),
184+
Message: fmt.Sprintf("status field %q must be marked as optional", fieldName),
185+
SuggestedFixes: []analysis.SuggestedFix{
186+
{
187+
Message: "add the optional marker",
188+
TextEdits: []analysis.TextEdit{
189+
{
190+
// Position at the beginning of the line of the field
191+
Pos: field.Pos(),
192+
// Insert the marker before the field
193+
NewText: fmt.Appendf(nil, "// +%s\n", a.preferredOptionalMarker),
194+
},
195+
},
196+
},
197+
},
198+
})
199+
}
200+
201+
// createMarkerRemovalEdits creates text edits to remove required markers.
202+
// TODO: consolidate the logic for removing markers with other linters.
203+
func createMarkerRemovalEdits(fieldMarkers markershelper.MarkerSet) []analysis.TextEdit {
204+
var textEdits []analysis.TextEdit
205+
206+
// Handle standard required markers
207+
if fieldMarkers.Has(markers.RequiredMarker) {
208+
for _, marker := range fieldMarkers[markers.RequiredMarker] {
209+
textEdits = append(textEdits, analysis.TextEdit{
210+
Pos: marker.Pos,
211+
End: marker.End + 1,
212+
NewText: []byte(""),
213+
})
214+
}
215+
}
216+
217+
// Handle kubebuilder required markers
218+
if fieldMarkers.Has(markers.KubebuilderRequiredMarker) {
219+
for _, marker := range fieldMarkers[markers.KubebuilderRequiredMarker] {
220+
textEdits = append(textEdits, analysis.TextEdit{
221+
Pos: marker.Pos,
222+
End: marker.End + 1,
223+
NewText: []byte(""),
224+
})
225+
}
226+
}
227+
228+
// Handle k8s required markers
229+
if fieldMarkers.Has(markers.K8sRequiredMarker) {
230+
for _, marker := range fieldMarkers[markers.K8sRequiredMarker] {
231+
textEdits = append(textEdits, analysis.TextEdit{
232+
Pos: marker.Pos,
233+
End: marker.End + 1,
234+
NewText: []byte(""),
235+
})
236+
}
237+
}
238+
239+
return textEdits
240+
}
241+
242+
// getStructFromField extracts the struct type from an AST Field.
243+
func getStructFromField(field *ast.Field) *ast.StructType {
244+
ident, ok := field.Type.(*ast.Ident)
245+
if !ok {
246+
return nil
247+
}
248+
249+
typeSpec, ok := ident.Obj.Decl.(*ast.TypeSpec)
250+
if !ok {
251+
return nil
252+
}
253+
254+
structType, ok := typeSpec.Type.(*ast.StructType)
255+
if !ok {
256+
return nil
257+
}
258+
259+
return structType
260+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
/*
2+
Copyright 2025 The Kubernetes Authors.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
package statusoptional
17+
18+
import (
19+
"testing"
20+
21+
"golang.org/x/tools/go/analysis/analysistest"
22+
"sigs.k8s.io/kube-api-linter/pkg/markers"
23+
)
24+
25+
func Test(t *testing.T) {
26+
testdata := analysistest.TestData()
27+
analysistest.RunWithSuggestedFixes(t, testdata, newAnalyzer(markers.OptionalMarker), "a")
28+
}
29+
30+
func TestWithKubebuilderOptionalMarker(t *testing.T) {
31+
testdata := analysistest.TestData()
32+
analysistest.RunWithSuggestedFixes(t, testdata, newAnalyzer(markers.KubebuilderOptionalMarker), "b")
33+
}
34+
35+
func TestWithK8sOptionalMarker(t *testing.T) {
36+
testdata := analysistest.TestData()
37+
analysistest.RunWithSuggestedFixes(t, testdata, newAnalyzer(markers.K8sOptionalMarker), "c")
38+
}

pkg/analysis/statusoptional/doc.go

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
/*
2+
Copyright 2025 The Kubernetes Authors.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
/*
18+
The statusoptional linter ensures that all first-level children fields within a status struct
19+
are marked as optional.
20+
21+
This is important because status fields should be optional to allow for partial updates
22+
and backward compatibility.
23+
24+
This linter checks:
25+
1. For structs with a JSON tag of "status"
26+
2. All direct child fields of the status struct
27+
3. Ensures each child field has an optional marker
28+
29+
The linter will report an issue if any field in the status struct is not marked as optional
30+
and will suggest a fix to add the appropriate optional marker.
31+
*/
32+
package statusoptional

0 commit comments

Comments
 (0)