Skip to content

Commit 2627b31

Browse files
authored
Merge pull request #85 from sivchari/ssatags-linter
Introduce SSATags linter
2 parents e719da1 + f8a2a6f commit 2627b31

File tree

14 files changed

+949
-1
lines changed

14 files changed

+949
-1
lines changed

docs/linters.md

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
- [OptionalFields](#optionalfields) - Validates optional field conventions
1414
- [OptionalOrRequired](#optionalorrequired) - Ensures fields are explicitly marked as optional or required
1515
- [RequiredFields](#requiredfields) - Validates required field conventions
16+
- [SSATags](#ssatags) - Ensures proper Server-Side Apply (SSA) tags on array fields
1617
- [StatusOptional](#statusoptional) - Ensures status fields are marked as optional
1718
- [StatusSubresource](#statussubresource) - Validates status subresource configuration
1819
- [UniqueMarkers](#uniquemarkers) - Ensures unique marker definitions
@@ -248,6 +249,45 @@ It will suggest to remove the pointer from the field, and update the `json` tag
248249
If you prefer not to suggest fixes for pointers in required fields, you can change the `pointerPolicy` to `Warn`.
249250
The linter will then only suggest to remove the `omitempty` value from the `json` tag.
250251

252+
## SSATags
253+
254+
The `ssatags` linter ensures that array fields in Kubernetes API objects have the appropriate
255+
listType markers (atomic, set, or map) for proper Server-Side Apply behavior.
256+
257+
Server-Side Apply (SSA) is a Kubernetes feature that allows multiple controllers to manage
258+
different parts of an object. The listType markers help SSA understand how to merge arrays:
259+
260+
- listType=atomic: The entire list is replaced when updated
261+
- listType=set: List elements are treated as a set (no duplicates, order doesn't matter)
262+
- listType=map: Elements are identified by specific key fields for granular updates
263+
264+
**Important Note on listType=set:**
265+
The use of listType=set is discouraged for object arrays due to Server-Side Apply
266+
compatibility issues. When multiple controllers attempt to apply changes to an object
267+
array with listType=set, the merge behavior can be unpredictable and may lead to
268+
data loss or unexpected conflicts. For object arrays, use listType=atomic for simple
269+
replacement semantics or listType=map for granular field-level merging.
270+
listType=set is safe to use with primitive arrays (strings, integers, etc.).
271+
272+
The linter checks for:
273+
274+
1. Missing listType markers on array fields
275+
2. Invalid listType values (must be atomic, set, or map)
276+
3. Usage of listType=set on object arrays (discouraged due to compatibility issues)
277+
4. Missing listMapKey markers for listType=map arrays
278+
5. Incorrect usage of listType=map on primitive arrays
279+
280+
### Configuration
281+
282+
```yaml
283+
lintersConfig:
284+
ssaTags:
285+
listTypeSetUsage: Warn | Ignore # The policy for listType=set usage on object arrays. Defaults to `Warn`.
286+
```
287+
288+
**Note:** listMapKey validation is always enforced and cannot be disabled. This ensures proper
289+
Server-Side Apply behavior for arrays using listType=map.
290+
251291
## StatusOptional
252292
253293
The `statusoptional` linter checks that all first-level children fields within a status struct are marked as optional.
@@ -304,4 +344,4 @@ Taking the example configuration from above:
304344
- Marker definitions of `custom:SomeCustomMarker:fruit=apple,color=red` and `custom:SomeCustomMarker:fruit=apple,color=green` would violate the uniqueness requirement and be flagged.
305345
- Marker definitions of `custom:SomeCustomMarker:fruit=apple,color=red` and `custom:SomeCustomMarker:fruit=orange,color=red` would _not_ violate the uniqueness requirement.
306346

307-
Each entry in `customMarkers` must have a unique `identifier`.
347+
Each entry in `customMarkers` must have a unique `identifier`.

pkg/analysis/registry.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import (
3232
"sigs.k8s.io/kube-api-linter/pkg/analysis/optionalfields"
3333
"sigs.k8s.io/kube-api-linter/pkg/analysis/optionalorrequired"
3434
"sigs.k8s.io/kube-api-linter/pkg/analysis/requiredfields"
35+
"sigs.k8s.io/kube-api-linter/pkg/analysis/ssatags"
3536
"sigs.k8s.io/kube-api-linter/pkg/analysis/statusoptional"
3637
"sigs.k8s.io/kube-api-linter/pkg/analysis/statussubresource"
3738
"sigs.k8s.io/kube-api-linter/pkg/analysis/uniquemarkers"
@@ -89,6 +90,7 @@ func NewRegistry() Registry {
8990
optionalfields.Initializer(),
9091
optionalorrequired.Initializer(),
9192
requiredfields.Initializer(),
93+
ssatags.Initializer(),
9294
statusoptional.Initializer(),
9395
statussubresource.Initializer(),
9496
uniquemarkers.Initializer(),

pkg/analysis/registry_test.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ var _ = Describe("Registry", func() {
4242
"optionalfields",
4343
"optionalorrequired",
4444
"requiredfields",
45+
"ssatags",
4546
"statusoptional",
4647
"uniquemarkers",
4748
))
@@ -65,6 +66,7 @@ var _ = Describe("Registry", func() {
6566
"optionalfields",
6667
"optionalorrequired",
6768
"requiredfields",
69+
"ssatags",
6870
"statusoptional",
6971
"statussubresource",
7072
"uniquemarkers",

pkg/analysis/ssatags/analyzer.go

Lines changed: 262 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,262 @@
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 ssatags
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+
"sigs.k8s.io/kube-api-linter/pkg/analysis/utils"
29+
"sigs.k8s.io/kube-api-linter/pkg/config"
30+
kubebuildermarkers "sigs.k8s.io/kube-api-linter/pkg/markers"
31+
)
32+
33+
const name = "ssatags"
34+
35+
const (
36+
listTypeAtomic = "atomic"
37+
listTypeSet = "set"
38+
listTypeMap = "map"
39+
)
40+
41+
type analyzer struct {
42+
cfg config.SSATagsConfig
43+
}
44+
45+
func newAnalyzer(cfg config.SSATagsConfig) *analysis.Analyzer {
46+
defaultConfig(&cfg)
47+
48+
a := &analyzer{
49+
cfg: cfg,
50+
}
51+
52+
return &analysis.Analyzer{
53+
Name: name,
54+
Doc: "Check that all array types in the API have a listType tag and the usage of the tags is correct",
55+
Run: a.run,
56+
Requires: []*analysis.Analyzer{inspector.Analyzer, extractjsontags.Analyzer},
57+
}
58+
}
59+
60+
func (a *analyzer) run(pass *analysis.Pass) (any, error) {
61+
inspect, ok := pass.ResultOf[inspector.Analyzer].(inspector.Inspector)
62+
if !ok {
63+
return nil, kalerrors.ErrCouldNotGetInspector
64+
}
65+
66+
inspect.InspectFields(func(field *ast.Field, stack []ast.Node, jsonTagInfo extractjsontags.FieldTagInfo, markersAccess markers.Markers) {
67+
a.checkField(pass, field, markersAccess)
68+
})
69+
70+
return nil, nil //nolint:nilnil
71+
}
72+
73+
func (a *analyzer) checkField(pass *analysis.Pass, field *ast.Field, markersAccess markers.Markers) {
74+
if !utils.IsArrayTypeOrAlias(pass, field) {
75+
return
76+
}
77+
78+
fieldMarkers := markersAccess.FieldMarkers(field)
79+
if fieldMarkers == nil {
80+
return
81+
}
82+
83+
fieldName := utils.FieldName(field)
84+
listTypeMarkers := fieldMarkers.Get(kubebuildermarkers.KubebuilderListTypeMarker)
85+
86+
if len(listTypeMarkers) == 0 {
87+
pass.Report(analysis.Diagnostic{
88+
Pos: field.Pos(),
89+
Message: fmt.Sprintf("%s should have a listType marker for proper Server-Side Apply behavior (atomic, set, or map)", fieldName),
90+
})
91+
92+
return
93+
}
94+
95+
for _, marker := range listTypeMarkers {
96+
listType := marker.Expressions[""]
97+
98+
a.checkListTypeMarker(pass, listType, field)
99+
100+
if listType == listTypeMap {
101+
a.checkListTypeMap(pass, fieldMarkers, field)
102+
}
103+
104+
if listType == listTypeSet {
105+
a.checkListTypeSet(pass, field)
106+
}
107+
}
108+
}
109+
110+
func (a *analyzer) checkListTypeMarker(pass *analysis.Pass, listType string, field *ast.Field) {
111+
fieldName := utils.FieldName(field)
112+
113+
if !validListType(listType) {
114+
pass.Report(analysis.Diagnostic{
115+
Pos: field.Pos(),
116+
Message: fmt.Sprintf("%s has invalid listType %q, must be one of: atomic, set, map", fieldName, listType),
117+
})
118+
119+
return
120+
}
121+
}
122+
123+
func (a *analyzer) checkListTypeMap(pass *analysis.Pass, fieldMarkers markers.MarkerSet, field *ast.Field) {
124+
listMapKeyMarkers := fieldMarkers.Get(kubebuildermarkers.KubebuilderListMapKeyMarker)
125+
fieldName := utils.FieldName(field)
126+
127+
isObjectList := utils.IsObjectList(pass, field)
128+
129+
if !isObjectList {
130+
pass.Report(analysis.Diagnostic{
131+
Pos: field.Pos(),
132+
Message: fmt.Sprintf("%s with listType=map can only be used for object lists, not primitive lists", fieldName),
133+
})
134+
135+
return
136+
}
137+
138+
if len(listMapKeyMarkers) == 0 {
139+
pass.Report(analysis.Diagnostic{
140+
Pos: field.Pos(),
141+
Message: fmt.Sprintf("%s with listType=map must have at least one listMapKey marker", fieldName),
142+
})
143+
144+
return
145+
}
146+
147+
a.validateListMapKeys(pass, field, listMapKeyMarkers)
148+
}
149+
150+
func (a *analyzer) checkListTypeSet(pass *analysis.Pass, field *ast.Field) {
151+
if a.cfg.ListTypeSetUsage == config.SSATagsListTypeSetUsageIgnore {
152+
return
153+
}
154+
155+
isObjectList := utils.IsObjectList(pass, field)
156+
if !isObjectList {
157+
return
158+
}
159+
160+
fieldName := utils.FieldName(field)
161+
diagnostic := analysis.Diagnostic{
162+
Pos: field.Pos(),
163+
Message: fmt.Sprintf("%s with listType=set is not recommended due to Server-Side Apply compatibility issues. Consider using listType=%s or listType=%s instead", fieldName, listTypeAtomic, listTypeMap),
164+
}
165+
166+
pass.Report(diagnostic)
167+
}
168+
169+
func (a *analyzer) validateListMapKeys(pass *analysis.Pass, field *ast.Field, listMapKeyMarkers []markers.Marker) {
170+
jsonTags, ok := pass.ResultOf[extractjsontags.Analyzer].(extractjsontags.StructFieldTags)
171+
if !ok {
172+
return
173+
}
174+
175+
structFields := a.getStructFieldsFromField(pass, field)
176+
if structFields == nil {
177+
return
178+
}
179+
180+
fieldName := utils.FieldName(field)
181+
182+
for _, marker := range listMapKeyMarkers {
183+
keyName := marker.Expressions[""]
184+
if keyName == "" {
185+
continue
186+
}
187+
188+
if !a.hasFieldWithJSONTag(structFields, jsonTags, keyName) {
189+
pass.Report(analysis.Diagnostic{
190+
Pos: field.Pos(),
191+
Message: fmt.Sprintf("%s listMapKey %q does not exist as a field in the struct", fieldName, keyName),
192+
})
193+
}
194+
}
195+
}
196+
197+
func (a *analyzer) getStructFieldsFromField(pass *analysis.Pass, field *ast.Field) *ast.FieldList {
198+
var elementType ast.Expr
199+
200+
// Get the element type from array or field type
201+
if arrayType, ok := field.Type.(*ast.ArrayType); ok {
202+
elementType = arrayType.Elt
203+
} else {
204+
elementType = field.Type
205+
}
206+
207+
return a.getStructFieldsFromExpr(pass, elementType)
208+
}
209+
210+
func (a *analyzer) getStructFieldsFromExpr(pass *analysis.Pass, expr ast.Expr) *ast.FieldList {
211+
switch elementType := expr.(type) {
212+
case *ast.Ident:
213+
typeSpec, ok := utils.LookupTypeSpec(pass, elementType)
214+
if !ok {
215+
return nil
216+
}
217+
218+
structType, ok := typeSpec.Type.(*ast.StructType)
219+
if !ok {
220+
return nil
221+
}
222+
223+
return structType.Fields
224+
case *ast.StarExpr:
225+
return a.getStructFieldsFromExpr(pass, elementType.X)
226+
case *ast.SelectorExpr:
227+
return nil
228+
}
229+
230+
return nil
231+
}
232+
233+
func (a *analyzer) hasFieldWithJSONTag(fields *ast.FieldList, jsonTags extractjsontags.StructFieldTags, fieldName string) bool {
234+
if fields == nil {
235+
return false
236+
}
237+
238+
for _, field := range fields.List {
239+
tagInfo := jsonTags.FieldTags(field)
240+
241+
if tagInfo.Name == fieldName {
242+
return true
243+
}
244+
}
245+
246+
return false
247+
}
248+
249+
func validListType(listType string) bool {
250+
switch listType {
251+
case listTypeAtomic, listTypeSet, listTypeMap:
252+
return true
253+
default:
254+
return false
255+
}
256+
}
257+
258+
func defaultConfig(cfg *config.SSATagsConfig) {
259+
if cfg.ListTypeSetUsage == "" {
260+
cfg.ListTypeSetUsage = config.SSATagsListTypeSetUsageWarn
261+
}
262+
}

0 commit comments

Comments
 (0)