Skip to content

Commit 2f69191

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 2e78eb0 commit 2f69191

File tree

9 files changed

+427
-0
lines changed

9 files changed

+427
-0
lines changed

README.md

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

282+
## StatusOptional
283+
284+
The `statusoptional` linter checks that all first-level children fields within a status struct are marked as optional.
285+
286+
This is important because status fields should be optional to allow for partial updates and backward compatibility.
287+
The linter ensures that all direct child fields of any status struct have either the `// +optional` or
288+
`// +kubebuilder:validation:Optional` marker.
289+
290+
### Fixes
291+
292+
The `statusoptional` linter can automatically fix fields in status structs that are not marked as optional.
293+
294+
It will suggest adding the `// +optional` marker to any status field that is missing it.
295+
282296
## StatusSubresource
283297

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

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: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
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+
"sigs.k8s.io/kube-api-linter/pkg/analysis/helpers/markers"
28+
)
29+
30+
const (
31+
name = "statusoptional"
32+
33+
statusJSONTag = "status"
34+
35+
// OptionalMarker is the marker that indicates that a field is optional.
36+
optionalMarker = "optional"
37+
38+
// KubebuilderOptionalMarker is the marker that indicates that a field is optional in kubebuilder.
39+
kubebuilderOptionalMarker = "kubebuilder:validation:Optional"
40+
)
41+
42+
func init() {
43+
markers.DefaultRegistry().Register(
44+
optionalMarker,
45+
kubebuilderOptionalMarker,
46+
)
47+
}
48+
49+
type analyzer struct{}
50+
51+
// newAnalyzer creates a new analyzer.
52+
func newAnalyzer() *analysis.Analyzer {
53+
a := &analyzer{}
54+
55+
return &analysis.Analyzer{
56+
Name: name,
57+
Doc: "Checks that all first-level children fields within status struct are marked as optional",
58+
Run: a.run,
59+
Requires: []*analysis.Analyzer{inspector.Analyzer},
60+
}
61+
}
62+
63+
func (a *analyzer) run(pass *analysis.Pass) (interface{}, error) {
64+
inspect, ok := pass.ResultOf[inspector.Analyzer].(inspector.Inspector)
65+
if !ok {
66+
return nil, kalerrors.ErrCouldNotGetInspector
67+
}
68+
69+
inspect.InspectFields(func(field *ast.Field, stack []ast.Node, jsonTagInfo extractjsontags.FieldTagInfo, markersAccess markers.Markers) {
70+
if jsonTagInfo.Name != statusJSONTag {
71+
return
72+
}
73+
74+
ident, ok := field.Type.(*ast.Ident)
75+
if !ok {
76+
return
77+
}
78+
79+
tSpec, ok := ident.Obj.Decl.(*ast.TypeSpec)
80+
if !ok {
81+
return
82+
}
83+
84+
statusType, ok := tSpec.Type.(*ast.StructType)
85+
if !ok {
86+
return
87+
}
88+
89+
a.checkStatusStruct(pass, statusType, markersAccess)
90+
})
91+
92+
return nil, nil //nolint:nilnil
93+
}
94+
95+
func (a *analyzer) checkStatusStruct(pass *analysis.Pass, statusType *ast.StructType, markersAccess markers.Markers) {
96+
if statusType.Fields == nil || statusType.Fields.List == nil {
97+
return
98+
}
99+
100+
// Check each child field of the status struct
101+
for _, childField := range statusType.Fields.List {
102+
fieldName := a.getFieldName(childField)
103+
if fieldName == "" {
104+
continue
105+
}
106+
107+
// Check if the field has the required optional markers
108+
a.checkFieldForOptionalMarker(pass, childField, fieldName, markersAccess)
109+
110+
// Check embedded structs recursively
111+
a.checkEmbeddedStruct(pass, childField, markersAccess)
112+
}
113+
}
114+
115+
// getFieldName extracts the field name from an AST field
116+
// TODO: move it to utils package.
117+
func (a *analyzer) getFieldName(field *ast.Field) string {
118+
if len(field.Names) > 0 {
119+
return field.Names[0].Name
120+
}
121+
122+
// For embedded fields
123+
if ident, ok := field.Type.(*ast.Ident); ok {
124+
return ident.Name
125+
}
126+
127+
return ""
128+
}
129+
130+
// checkFieldForOptionalMarker checks if a field has the required optional markers.
131+
func (a *analyzer) checkFieldForOptionalMarker(pass *analysis.Pass, field *ast.Field, fieldName string, markersAccess markers.Markers) {
132+
fieldMarkers := markersAccess.FieldMarkers(field)
133+
134+
// Check if the field has either the optional or kubebuilder:validation:Optional marker
135+
if !fieldMarkers.Has(optionalMarker) && !fieldMarkers.Has(kubebuilderOptionalMarker) {
136+
// Report the error and suggest a fix to add the optional marker
137+
pass.Report(analysis.Diagnostic{
138+
Pos: field.Pos(),
139+
Message: fmt.Sprintf("status field %q must be marked as optional", fieldName),
140+
SuggestedFixes: []analysis.SuggestedFix{
141+
{
142+
Message: "add the optional marker",
143+
TextEdits: []analysis.TextEdit{
144+
{
145+
// Position at the beginning of the line of the field
146+
Pos: field.Pos(),
147+
// Insert the marker before the field
148+
NewText: []byte("// +optional\n"),
149+
},
150+
},
151+
},
152+
},
153+
})
154+
}
155+
}
156+
157+
// checkEmbeddedStruct checks if a field is an embedded struct and if so,
158+
// recursively checks its children fields.
159+
func (a *analyzer) checkEmbeddedStruct(pass *analysis.Pass, field *ast.Field, markersAccess markers.Markers) {
160+
// Only embedded fields have no names
161+
if len(field.Names) > 0 {
162+
return
163+
}
164+
165+
ident, ok := field.Type.(*ast.Ident)
166+
if !ok {
167+
return
168+
}
169+
170+
typeSpec, ok := ident.Obj.Decl.(*ast.TypeSpec)
171+
if !ok {
172+
return
173+
}
174+
175+
embeddedStruct, ok := typeSpec.Type.(*ast.StructType)
176+
if !ok {
177+
return
178+
}
179+
180+
a.checkStatusStruct(pass, embeddedStruct, markersAccess)
181+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
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+
)
23+
24+
func Test(t *testing.T) {
25+
testdata := analysistest.TestData()
26+
analysistest.RunWithSuggestedFixes(t, testdata, newAnalyzer(), "a")
27+
}

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
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
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+
"golang.org/x/tools/go/analysis"
20+
21+
"sigs.k8s.io/kube-api-linter/pkg/config"
22+
)
23+
24+
// Initializer returns the AnalyzerInitializer for this
25+
// Analyzer so that it can be added to the registry.
26+
func Initializer() initializer {
27+
return initializer{}
28+
}
29+
30+
// initializer implements the AnalyzerInitializer interface.
31+
type initializer struct{}
32+
33+
// Name returns the name of the Analyzer.
34+
func (initializer) Name() string {
35+
return name
36+
}
37+
38+
// Init returns the initialized Analyzer.
39+
func (initializer) Init(cfg config.LintersConfig) (*analysis.Analyzer, error) {
40+
// Currently no configuration is needed for this linter
41+
return newAnalyzer(), nil
42+
}
43+
44+
// Default determines whether this Analyzer is on by default, or not.
45+
func (initializer) Default() bool {
46+
return true
47+
}

0 commit comments

Comments
 (0)