Skip to content

Commit 357ccbf

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 357ccbf

File tree

8 files changed

+326
-0
lines changed

8 files changed

+326
-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: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
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+
"go/token"
22+
23+
"golang.org/x/tools/go/analysis"
24+
25+
kalerrors "sigs.k8s.io/kube-api-linter/pkg/analysis/errors"
26+
"sigs.k8s.io/kube-api-linter/pkg/analysis/helpers/extractjsontags"
27+
"sigs.k8s.io/kube-api-linter/pkg/analysis/helpers/inspector"
28+
"sigs.k8s.io/kube-api-linter/pkg/analysis/helpers/markers"
29+
)
30+
31+
const (
32+
name = "statusoptional"
33+
34+
statusJSONTag = "status"
35+
36+
// OptionalMarker is the marker that indicates that a field is optional.
37+
OptionalMarker = "optional"
38+
39+
// KubebuilderOptionalMarker is the marker that indicates that a field is optional in kubebuilder.
40+
KubebuilderOptionalMarker = "kubebuilder:validation:Optional"
41+
)
42+
43+
func init() {
44+
markers.DefaultRegistry().Register(
45+
OptionalMarker,
46+
KubebuilderOptionalMarker,
47+
)
48+
}
49+
50+
type analyzer struct{}
51+
52+
// newAnalyzer creates a new analyzer.
53+
func newAnalyzer() *analysis.Analyzer {
54+
a := &analyzer{}
55+
56+
return &analysis.Analyzer{
57+
Name: name,
58+
Doc: "Checks that all first-level children fields within status struct are marked as optional",
59+
Run: a.run,
60+
Requires: []*analysis.Analyzer{inspector.Analyzer},
61+
}
62+
}
63+
64+
func (a *analyzer) run(pass *analysis.Pass) (interface{}, error) {
65+
inspect, ok := pass.ResultOf[inspector.Analyzer].(inspector.Inspector)
66+
if !ok {
67+
return nil, kalerrors.ErrCouldNotGetInspector
68+
}
69+
70+
inspect.InspectFields(func(field *ast.Field, stack []ast.Node, jsonTagInfo extractjsontags.FieldTagInfo, markersAccess markers.Markers) {
71+
if jsonTagInfo.Name != statusJSONTag {
72+
return
73+
}
74+
75+
ident, ok := field.Type.(*ast.Ident)
76+
if !ok {
77+
return
78+
}
79+
80+
tSpec, ok := ident.Obj.Decl.(*ast.TypeSpec)
81+
if !ok {
82+
return
83+
}
84+
85+
statusType, ok := tSpec.Type.(*ast.StructType)
86+
if !ok {
87+
return
88+
}
89+
90+
a.checkStatusStruct(pass, statusType, markersAccess)
91+
})
92+
93+
return nil, nil //nolint:nilnil
94+
}
95+
96+
func (a *analyzer) checkStatusStruct(pass *analysis.Pass, statusType *ast.StructType, markersAccess markers.Markers) {
97+
if statusType.Fields == nil || statusType.Fields.List == nil {
98+
return
99+
}
100+
101+
// Check each child field of the status struct
102+
for _, childField := range statusType.Fields.List {
103+
var fieldName string
104+
105+
if len(childField.Names) == 0 {
106+
switch t := childField.Type.(type) {
107+
case *ast.Ident:
108+
fieldName = t.Name
109+
default:
110+
continue
111+
}
112+
} else {
113+
fieldName = childField.Names[0].Name
114+
}
115+
116+
fieldMarkers := markersAccess.FieldMarkers(childField)
117+
118+
// Check if the field has either the optional or kubebuilder:validation:Optional marker
119+
if !fieldMarkers.Has(OptionalMarker) && !fieldMarkers.Has(KubebuilderOptionalMarker) {
120+
// Report the error and suggest a fix to add the optional marker
121+
pass.Report(analysis.Diagnostic{
122+
Pos: childField.Pos(),
123+
Message: fmt.Sprintf("status field %q must be marked as optional", fieldName),
124+
SuggestedFixes: []analysis.SuggestedFix{
125+
{
126+
Message: "add the optional marker",
127+
TextEdits: []analysis.TextEdit{
128+
{
129+
// Position at the beginning of the line of the field
130+
Pos: childField.Pos() - token.Pos(len(fieldName)),
131+
// Insert the marker before the field
132+
NewText: []byte("// +optional\n\t"),
133+
},
134+
},
135+
},
136+
},
137+
})
138+
}
139+
}
140+
}
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.Run(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+
package statusoptional
17+
18+
/*
19+
The statusoptional linter ensures that all first-level children fields within a status struct
20+
are marked as optional.
21+
22+
This is important because status fields should be optional to allow for partial updates
23+
and backward compatibility.
24+
25+
This linter checks:
26+
1. For structs with a JSON tag of "status"
27+
2. All direct child fields of the status struct
28+
3. Ensures each child field has an optional marker
29+
30+
The linter will report an issue if any field in the status struct is not marked as optional
31+
and will suggest a fix to add the appropriate optional marker.
32+
*/
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+
}
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
/*
2+
This file contains test cases for the statusoptional linter.
3+
*/
4+
package a
5+
6+
// MyResource is a sample resource with a status field containing not optional fields.
7+
type MyResource struct {
8+
// +optional
9+
Spec MyResourceSpec `json:"spec"`
10+
11+
Status MyResourceStatus `json:"status"`
12+
}
13+
14+
// MyResourceSpec is a sample spec.
15+
type MyResourceSpec struct {
16+
// +optional
17+
Foo string `json:"foo"`
18+
}
19+
20+
// MyResourceStatus is a sample status with a mix of optional and non-optional fields.
21+
type MyResourceStatus struct {
22+
// This field is not marked as optional and should trigger a warning.
23+
Phase string `json:"phase"` // want "status field \"Phase\" must be marked as optional"
24+
25+
// +optional
26+
Conditions []string `json:"conditions"`
27+
28+
// This field is not marked as optional and should trigger a warning.
29+
Message string `json:"message"` // want "status field \"Message\" must be marked as optional"
30+
31+
// +kubebuilder:validation:Optional
32+
Reason string `json:"reason"`
33+
34+
State // want "status field \"State\" must be marked as optional"
35+
}
36+
37+
type State struct {
38+
Ready bool `json:"ready"`
39+
}
40+
41+
// CompliantResource is a sample resource with all status fields marked as optional.
42+
type CompliantResource struct {
43+
// +optional
44+
Spec MyResourceSpec `json:"spec"`
45+
46+
Status CompliantStatus `json:"status"`
47+
}
48+
49+
// CompliantStatus has all fields properly marked as optional.
50+
type CompliantStatus struct {
51+
// +optional
52+
Phase string `json:"phase"`
53+
54+
// +optional
55+
Conditions []string `json:"conditions"`
56+
57+
// +optional
58+
Message string `json:"message"`
59+
60+
// +kubebuilder:validation:Optional
61+
Reason string `json:"reason"`
62+
}

0 commit comments

Comments
 (0)