Skip to content

Commit c9d4a4c

Browse files
committed
Add Conditions linter to verify conditions type, tags and markers
1 parent 52306d5 commit c9d4a4c

File tree

17 files changed

+1280
-0
lines changed

17 files changed

+1280
-0
lines changed

README.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,41 @@ allowing for customisation or automatic copmilation of the project should it not
128128

129129
# Linters
130130

131+
## Conditions
132+
133+
The `conditions` linter checks that `Conditions` fields in the API types are correctly formatted.
134+
The `Conditions` field should be a slice of `metav1.Condition` with the following tags and markers:
135+
136+
```go
137+
// +listType=map
138+
// +listMapKey=type
139+
// +patchStrategy=merge
140+
// +patchMergeKey=type
141+
// +optional
142+
Conditions []metav1.Condition `json:"conditions,omitempty" patchStrategy:"merge" patchMergeKey:"type" protobuf:"bytes,1,opt,name=conditions"`
143+
```
144+
145+
Conditions are idiomatically the first field within the status struct, and the linter will highlight when the Conditions are not the first field.
146+
147+
### Configuration
148+
149+
```yaml
150+
lintersConfig:
151+
conditions:
152+
isFirstField: Warn | Ignore # The policy for the Conditions field being the first field. Defaults to `Warn`.
153+
useProtobuf: SuggestFix | Warn | Ignore # The policy for the protobuf tag on the Conditions field. Defaults to `SuggestFix`.
154+
```
155+
156+
### Fixes (via standalone binary only)
157+
158+
The `conditions` linter can automatically fix the tags on the `Conditions` field.
159+
When they do not match the expected format, the linter will suggest to update the tags to match the expected format.
160+
161+
For CRDs, protobuf tags are not expected. By setting the `useProtobuf` configuration to `Ignore`, the linter will not suggest to add the protobuf tag to the `Conditions` field tags.
162+
163+
The linter will also suggest to add missing markers.
164+
If any of the 5 markers in the example above are missing, the linter will suggest to add them directly above the field.
165+
131166
## CommentStart
132167

133168
The `commentstart` linter checks that all comments in the API types start with the serialized form of the type they are commenting on.

pkg/analysis/conditions/analyzer.go

Lines changed: 301 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,301 @@
1+
package conditions
2+
3+
import (
4+
"errors"
5+
"fmt"
6+
"go/ast"
7+
"go/token"
8+
"strings"
9+
10+
"github.com/JoelSpeed/kal/pkg/analysis/helpers/extractjsontags"
11+
"github.com/JoelSpeed/kal/pkg/analysis/helpers/markers"
12+
"github.com/JoelSpeed/kal/pkg/config"
13+
"golang.org/x/tools/go/analysis"
14+
"golang.org/x/tools/go/analysis/passes/inspect"
15+
"golang.org/x/tools/go/ast/inspector"
16+
)
17+
18+
const (
19+
name = "conditions"
20+
21+
listTypeMap = "listType=map"
22+
listMapKeyType = "listMapKey=type"
23+
patchStrategy = "patchStrategy=merge"
24+
patchMergeKeyType = "patchMergeKey=type"
25+
optional = "optional"
26+
27+
expectedTagWithProtobufFmt = "`json:\"conditions,omitempty\" patchStrategy:\"merge\" patchMergeKey:\"type\" protobuf:\"bytes,%d,rep,name=conditions\"`"
28+
expectedTagWithoutProtobuf = "`json:\"conditions,omitempty\" patchStrategy:\"merge\" patchMergeKey:\"type\"`"
29+
)
30+
31+
var (
32+
errCouldNotGetInspector = errors.New("could not get inspector")
33+
errCouldNotGetMarkers = errors.New("could not get markers")
34+
)
35+
36+
type analyzer struct {
37+
isFirstField config.ConditionsFirstField
38+
useProtobuf config.ConditionsUseProtobuf
39+
}
40+
41+
// newAnalyzer creates a new analyzer.
42+
func newAnalyzer(cfg config.ConditionsConfig) *analysis.Analyzer {
43+
defaultConfig(&cfg)
44+
45+
a := &analyzer{
46+
isFirstField: cfg.IsFirstField,
47+
useProtobuf: cfg.UseProtobuf,
48+
}
49+
50+
return &analysis.Analyzer{
51+
Name: name,
52+
Doc: `Checks that all conditions type fields conform to the required conventions.`,
53+
Run: a.run,
54+
Requires: []*analysis.Analyzer{inspect.Analyzer, markers.Analyzer, extractjsontags.Analyzer},
55+
}
56+
}
57+
58+
func (a *analyzer) run(pass *analysis.Pass) (interface{}, error) {
59+
inspect, ok := pass.ResultOf[inspect.Analyzer].(*inspector.Inspector)
60+
if !ok {
61+
return nil, errCouldNotGetInspector
62+
}
63+
64+
markersAccess, ok := pass.ResultOf[markers.Analyzer].(markers.Markers)
65+
if !ok {
66+
return nil, errCouldNotGetMarkers
67+
}
68+
69+
// Filter to structs so that we can iterate over fields in a struct.
70+
nodeFilter := []ast.Node{
71+
(*ast.StructType)(nil),
72+
}
73+
74+
inspect.Preorder(nodeFilter, func(n ast.Node) {
75+
sTyp, ok := n.(*ast.StructType)
76+
if !ok {
77+
return
78+
}
79+
80+
if sTyp.Fields == nil {
81+
return
82+
}
83+
84+
for i, field := range sTyp.Fields.List {
85+
if field == nil || len(field.Names) == 0 {
86+
continue
87+
}
88+
89+
fieldName := field.Names[0].Name
90+
fieldMarkers := markersAccess.StructFieldMarkers(sTyp, fieldName)
91+
92+
a.checkField(pass, i, field, fieldMarkers)
93+
}
94+
})
95+
96+
return nil, nil //nolint:nilnil
97+
}
98+
99+
func (a *analyzer) checkField(pass *analysis.Pass, index int, field *ast.Field, fieldMarkers markers.MarkerSet) {
100+
if !fieldIsCalledConditions(field) {
101+
return
102+
}
103+
104+
if !isSliceMetaV1Condition(field) {
105+
pass.Reportf(field.Pos(), "Conditions field must be a slice of metav1.Condition")
106+
return
107+
}
108+
109+
checkFieldMarkers(pass, field, fieldMarkers)
110+
a.checkFieldTags(pass, index, field)
111+
112+
if a.isFirstField == config.ConditionsFirstFieldWarn && index != 0 {
113+
pass.Reportf(field.Pos(), "Conditions field must be the first field in the struct")
114+
}
115+
}
116+
117+
func checkFieldMarkers(pass *analysis.Pass, field *ast.Field, fieldMarkers markers.MarkerSet) {
118+
missingMarkers := []string{}
119+
120+
if !fieldMarkers.Has(listTypeMap) {
121+
missingMarkers = append(missingMarkers, listTypeMap)
122+
}
123+
124+
if !fieldMarkers.Has(listMapKeyType) {
125+
missingMarkers = append(missingMarkers, listMapKeyType)
126+
}
127+
128+
if !fieldMarkers.Has(patchStrategy) {
129+
missingMarkers = append(missingMarkers, patchStrategy)
130+
}
131+
132+
if !fieldMarkers.Has(patchMergeKeyType) {
133+
missingMarkers = append(missingMarkers, patchMergeKeyType)
134+
}
135+
136+
if !fieldMarkers.Has(optional) {
137+
missingMarkers = append(missingMarkers, optional)
138+
}
139+
140+
if len(missingMarkers) != 0 {
141+
pass.Report(analysis.Diagnostic{
142+
Pos: field.Pos(),
143+
End: field.End(),
144+
Message: "Conditions field is missing the following markers: " + strings.Join(missingMarkers, ", "),
145+
SuggestedFixes: []analysis.SuggestedFix{
146+
{
147+
Message: "Add missing markers",
148+
TextEdits: []analysis.TextEdit{
149+
{
150+
Pos: field.Pos(),
151+
End: token.NoPos,
152+
NewText: getNewMarkers(missingMarkers),
153+
},
154+
},
155+
},
156+
},
157+
})
158+
}
159+
}
160+
161+
func getNewMarkers(missingMarkers []string) []byte {
162+
var out string
163+
164+
for _, marker := range missingMarkers {
165+
out += "// +" + marker + "\n"
166+
}
167+
168+
return []byte(out)
169+
}
170+
171+
func (a *analyzer) checkFieldTags(pass *analysis.Pass, index int, field *ast.Field) {
172+
if field.Tag == nil {
173+
expectedTag := getExpectedTag(a.useProtobuf, a.isFirstField, index)
174+
175+
pass.Report(analysis.Diagnostic{
176+
Pos: field.Pos(),
177+
End: field.End(),
178+
Message: "Conditions field is missing tags, should be: " + expectedTag,
179+
SuggestedFixes: []analysis.SuggestedFix{
180+
{
181+
Message: "Add missing tags",
182+
TextEdits: []analysis.TextEdit{
183+
{
184+
Pos: field.End(),
185+
End: token.NoPos,
186+
NewText: []byte(expectedTag),
187+
},
188+
},
189+
},
190+
},
191+
})
192+
193+
return
194+
}
195+
196+
asExpected, shouldFix := tagIsAsExpected(field.Tag.Value, a.useProtobuf, a.isFirstField, index)
197+
if !asExpected {
198+
expectedTag := getExpectedTag(a.useProtobuf, a.isFirstField, index)
199+
200+
if !shouldFix {
201+
pass.Reportf(field.Tag.ValuePos, "Conditions field has incorrect tags, should be: %s", expectedTag)
202+
} else {
203+
pass.Report(analysis.Diagnostic{
204+
Pos: field.Tag.ValuePos,
205+
End: field.Tag.End(),
206+
Message: "Conditions field has incorrect tags, should be: " + expectedTag,
207+
SuggestedFixes: []analysis.SuggestedFix{
208+
{
209+
Message: "Update tags",
210+
TextEdits: []analysis.TextEdit{
211+
{
212+
Pos: field.Tag.ValuePos,
213+
End: field.Tag.End(),
214+
NewText: []byte(expectedTag),
215+
},
216+
},
217+
},
218+
},
219+
})
220+
}
221+
}
222+
}
223+
224+
func getExpectedTag(useProtobuf config.ConditionsUseProtobuf, isFirstField config.ConditionsFirstField, index int) string {
225+
if useProtobuf == config.ConditionsUseProtobufSuggestFix || useProtobuf == config.ConditionsUseProtobufWarn {
226+
i := 1
227+
if isFirstField == config.ConditionsFirstFieldIgnore {
228+
i = index + 1
229+
}
230+
231+
return fmt.Sprintf(expectedTagWithProtobufFmt, i)
232+
}
233+
234+
return expectedTagWithoutProtobuf
235+
}
236+
237+
func tagIsAsExpected(tag string, useProtobuf config.ConditionsUseProtobuf, isFirstField config.ConditionsFirstField, index int) (bool, bool) {
238+
switch useProtobuf {
239+
case config.ConditionsUseProtobufSuggestFix:
240+
return tag == getExpectedTag(config.ConditionsUseProtobufSuggestFix, isFirstField, index), true
241+
case config.ConditionsUseProtobufWarn:
242+
return tag == getExpectedTag(config.ConditionsUseProtobufWarn, isFirstField, index), false
243+
case config.ConditionsUseProtobufIgnore:
244+
return tag == getExpectedTag(config.ConditionsUseProtobufIgnore, isFirstField, index) || tag == getExpectedTag(config.ConditionsUseProtobufSuggestFix, isFirstField, index), true
245+
default:
246+
panic("unexpected useProtobuf value")
247+
}
248+
}
249+
250+
func fieldIsCalledConditions(field *ast.Field) bool {
251+
if field == nil {
252+
return false
253+
}
254+
255+
return len(field.Names) != 0 && field.Names[0] != nil && field.Names[0].Name == "Conditions"
256+
}
257+
258+
func isSliceMetaV1Condition(field *ast.Field) bool {
259+
if field == nil {
260+
return false
261+
}
262+
263+
// Field is not an array type.
264+
arr, ok := field.Type.(*ast.ArrayType)
265+
if !ok {
266+
return false
267+
}
268+
269+
// Array element is not imported.
270+
selector, ok := arr.Elt.(*ast.SelectorExpr)
271+
if !ok {
272+
return false
273+
}
274+
275+
pkg, ok := selector.X.(*ast.Ident)
276+
if !ok {
277+
return false
278+
}
279+
280+
// Array element is not imported from metav1.
281+
if selector.X == nil || pkg.Name != "metav1" {
282+
return false
283+
}
284+
285+
// Array element is not a metav1.Condition.
286+
if selector.Sel == nil || selector.Sel.Name != "Condition" {
287+
return false
288+
}
289+
290+
return true
291+
}
292+
293+
func defaultConfig(cfg *config.ConditionsConfig) {
294+
if cfg.IsFirstField == "" {
295+
cfg.IsFirstField = config.ConditionsFirstFieldWarn
296+
}
297+
298+
if cfg.UseProtobuf == "" {
299+
cfg.UseProtobuf = config.ConditionsUseProtobufSuggestFix
300+
}
301+
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
package conditions_test
2+
3+
import (
4+
"testing"
5+
6+
"github.com/JoelSpeed/kal/pkg/analysis/conditions"
7+
"github.com/JoelSpeed/kal/pkg/config"
8+
"golang.org/x/tools/go/analysis/analysistest"
9+
)
10+
11+
func TestDefaultConfiguration(t *testing.T) {
12+
testdata := analysistest.TestData()
13+
14+
a, err := conditions.Initializer().Init(config.LintersConfig{})
15+
if err != nil {
16+
t.Fatal(err)
17+
}
18+
19+
analysistest.RunWithSuggestedFixes(t, testdata, a, "a")
20+
}
21+
22+
func TestNotFieldFirst(t *testing.T) {
23+
testdata := analysistest.TestData()
24+
25+
a, err := conditions.Initializer().Init(config.LintersConfig{
26+
Conditions: config.ConditionsConfig{
27+
IsFirstField: config.ConditionsFirstFieldIgnore,
28+
},
29+
})
30+
if err != nil {
31+
t.Fatal(err)
32+
}
33+
34+
analysistest.RunWithSuggestedFixes(t, testdata, a, "b")
35+
}
36+
37+
func TestIgnoreProtobuf(t *testing.T) {
38+
testdata := analysistest.TestData()
39+
40+
a, err := conditions.Initializer().Init(config.LintersConfig{
41+
Conditions: config.ConditionsConfig{
42+
UseProtobuf: config.ConditionsUseProtobufIgnore,
43+
},
44+
})
45+
if err != nil {
46+
t.Fatal(err)
47+
}
48+
49+
analysistest.RunWithSuggestedFixes(t, testdata, a, "c")
50+
}

0 commit comments

Comments
 (0)