Skip to content

Commit 4dc9e20

Browse files
committed
(feat): markers: add support for parsing declarative validation tags
Signed-off-by: Bryce Palmer <[email protected]>
1 parent 502783c commit 4dc9e20

File tree

4 files changed

+274
-11
lines changed

4 files changed

+274
-11
lines changed

go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ require (
99
golang.org/x/tools v0.32.0
1010
gopkg.in/yaml.v3 v3.0.1
1111
k8s.io/apimachinery v0.32.3
12+
k8s.io/gengo/v2 v2.0.0-20250922181213-ec3ebc5fd46b
1213
k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738
1314
)
1415

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,5 +51,7 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
5151
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
5252
k8s.io/apimachinery v0.32.3 h1:JmDuDarhDmA/Li7j3aPrwhpNBA94Nvk5zLeOge9HH1U=
5353
k8s.io/apimachinery v0.32.3/go.mod h1:GpHVgxoKlTxClKcteaeuF1Ul/lDVb74KpZcxcmLDElE=
54+
k8s.io/gengo/v2 v2.0.0-20250922181213-ec3ebc5fd46b h1:gMplByicHV/TJBizHd9aVEsTYoJBnnUAT5MHlTkbjhQ=
55+
k8s.io/gengo/v2 v2.0.0-20250922181213-ec3ebc5fd46b/go.mod h1:CgujABENc3KuTrcsdpGmrrASjtQsWCT7R99mEV4U/fM=
5456
k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 h1:M3sRQVHv7vB20Xc2ybTt7ODCeFj6JSWYFzOFnYeS6Ro=
5557
k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0=

pkg/analysis/helpers/markers/analyzer.go

Lines changed: 91 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ limitations under the License.
1616
package markers
1717

1818
import (
19+
"fmt"
1920
"go/ast"
2021
"go/token"
2122
"reflect"
@@ -26,6 +27,8 @@ import (
2627
"golang.org/x/tools/go/analysis/passes/inspect"
2728
"golang.org/x/tools/go/ast/inspector"
2829

30+
"k8s.io/gengo/v2/codetags"
31+
2932
kalerrors "sigs.k8s.io/kube-api-linter/pkg/analysis/errors"
3033
)
3134

@@ -158,55 +161,78 @@ func run(pass *analysis.Pass) (any, error) {
158161
return results, nil
159162
}
160163

161-
func extractGenDeclMarkers(typ *ast.GenDecl, results *markers) {
164+
func extractGenDeclMarkers(typ *ast.GenDecl, results *markers) error {
162165
declMarkers := NewMarkerSet()
163166

164167
if typ.Doc != nil {
165168
for _, comment := range typ.Doc.List {
166-
if marker := extractMarker(comment); marker.Identifier != "" {
169+
marker, err := extractMarker(comment)
170+
if err != nil {
171+
return err
172+
}
173+
174+
if marker.Identifier != "" {
167175
declMarkers.Insert(marker)
168176
}
169177
}
170178
}
171179

172180
if len(typ.Specs) == 0 {
173-
return
181+
return nil
174182
}
175183

176184
tSpec, ok := typ.Specs[0].(*ast.TypeSpec)
177185
if !ok {
178-
return
186+
return nil
179187
}
180188

181189
results.insertTypeMarkers(tSpec, declMarkers)
182190

183191
if sTyp, ok := tSpec.Type.(*ast.StructType); ok {
184192
results.insertStructMarkers(sTyp, declMarkers)
185193
}
194+
195+
return nil
186196
}
187197

188-
func extractFieldMarkers(field *ast.Field, results *markers) {
198+
func extractFieldMarkers(field *ast.Field, results *markers) error {
189199
if field == nil || field.Doc == nil {
190-
return
200+
return nil
191201
}
192202

193203
fieldMarkers := NewMarkerSet()
194204

195205
for _, comment := range field.Doc.List {
196-
if marker := extractMarker(comment); marker.Identifier != "" {
206+
marker, err := extractMarker(comment)
207+
if err != nil {
208+
return err
209+
}
210+
211+
if marker.Identifier != "" {
197212
fieldMarkers.Insert(marker)
198213
}
199214
}
200215

201216
results.insertFieldMarkers(field, fieldMarkers)
217+
return nil
202218
}
203219

204-
func extractMarker(comment *ast.Comment) Marker {
220+
func extractMarker(comment *ast.Comment) (Marker, error) {
205221
if !strings.HasPrefix(comment.Text, "// +") {
206-
return Marker{}
222+
return Marker{}, nil
207223
}
208224

209225
markerContent := strings.TrimPrefix(comment.Text, "// +")
226+
227+
if isDeclarativeValidationMarker(markerContent) {
228+
marker, err := extractDeclarativeValidationMarker(markerContent, comment)
229+
if err != nil {
230+
return Marker{}, fmt.Errorf("parsing declarative validation marker %q: %w", markerContent, err)
231+
}
232+
233+
return *marker, nil
234+
}
235+
210236
id, expressions := extractMarkerIDAndExpressions(DefaultRegistry(), markerContent)
211237

212238
return Marker{
@@ -215,7 +241,7 @@ func extractMarker(comment *ast.Comment) Marker {
215241
RawComment: comment.Text,
216242
Pos: comment.Pos(),
217243
End: comment.End(),
218-
}
244+
}, nil
219245
}
220246

221247
func extractMarkerIDAndExpressions(knownMarkers Registry, marker string) (string, map[string]string) {
@@ -226,6 +252,52 @@ func extractMarkerIDAndExpressions(knownMarkers Registry, marker string) (string
226252
return extractUnknownMarkerIDAndExpressions(marker)
227253
}
228254

255+
func isDeclarativeValidationMarker(marker string) bool {
256+
return strings.HasPrefix(marker, "k8s:")
257+
}
258+
259+
func extractDeclarativeValidationMarker(marker string, comment *ast.Comment) (*Marker, error) {
260+
tag, err := codetags.Parse(marker)
261+
if err != nil {
262+
return nil, fmt.Errorf("encountered an error parsing declarative validation marker %q: %v", marker, err)
263+
}
264+
265+
return markerForTag(&tag, comment)
266+
}
267+
268+
func markerForTag(tag *codetags.Tag, comment *ast.Comment) (*Marker, error) {
269+
out := &Marker{
270+
Identifier: tag.Name,
271+
Expressions: make(map[string]string),
272+
RawComment: comment.Text,
273+
Pos: comment.Pos(),
274+
End: comment.End(),
275+
}
276+
277+
for _, arg := range tag.Args {
278+
out.Expressions[arg.Name] = arg.Value
279+
}
280+
281+
switch tag.ValueType {
282+
case codetags.ValueTypeString, codetags.ValueTypeInt, codetags.ValueTypeBool, codetags.ValueTypeRaw:
283+
// all resolvable to an exact string value
284+
out.Expressions["payload"] = tag.Value
285+
case codetags.ValueTypeNone:
286+
// nothing
287+
case codetags.ValueTypeTag:
288+
// TODO: Better position evaluation
289+
marker, err := markerForTag(tag.ValueTag, comment)
290+
if err != nil {
291+
return nil, err
292+
}
293+
out.Marker = marker
294+
default:
295+
return nil, fmt.Errorf("unknown tag value type %v", tag.ValueType)
296+
}
297+
298+
return out, nil
299+
}
300+
229301
func extractKnownMarkerIDAndExpressions(id string, marker string) (string, map[string]string) {
230302
return id, extractExpressions(strings.TrimPrefix(marker, id))
231303
}
@@ -311,9 +383,17 @@ type Marker struct {
311383
// Identifier is the value of the marker once the leading comment, '+', and expressions are trimmed.
312384
Identifier string
313385

314-
// Expressions are the set of expressions that have been specified for the marker
386+
// Expressions are the set of expressions that have been specified for the marker.
387+
// Marker named and unnamed arguments are included in this map. If the marker's payload is
388+
// not another marker, the key "payload" will contain the marker payload.
315389
Expressions map[string]string
316390

391+
// Marker is a nested marker. This is present in cases
392+
// where we've parsed a declarative validation tag
393+
// and it has another declarative validation tag in it's payload.
394+
// If this is nil, the payload was _not_ another dv tag.
395+
Marker *Marker
396+
317397
// RawComment is the raw comment line, unfiltered.
318398
RawComment string
319399

pkg/analysis/helpers/markers/analyzer_test.go

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@ limitations under the License.
1616
package markers
1717

1818
import (
19+
"errors"
20+
"fmt"
21+
"go/ast"
1922
"testing"
2023

2124
. "github.com/onsi/gomega"
@@ -146,3 +149,180 @@ func TestExtractMarkerIdAndExpressions(t *testing.T) {
146149
})
147150
}
148151
}
152+
153+
func TestExtractMarker(t *testing.T) {
154+
type testcase struct {
155+
name string
156+
comment *ast.Comment
157+
expect func(Marker) error
158+
}
159+
160+
testcases := []testcase{
161+
{
162+
name: "simple declarative validation marker",
163+
comment: &ast.Comment{
164+
Text: "// +k8s:required",
165+
},
166+
expect: func(m Marker) error {
167+
if m.Identifier != "k8s:required" {
168+
return fmt.Errorf("identifier %q did not match expected identifier %q", m.Identifier, "k8s:required")
169+
}
170+
171+
return nil
172+
},
173+
},
174+
{
175+
name: "declarative validation marker with a value",
176+
comment: &ast.Comment{
177+
Text: "// +k8s:maxLength=10",
178+
},
179+
expect: func(m Marker) error {
180+
if m.Identifier != "k8s:maxLength" {
181+
return fmt.Errorf("identifier %q did not match expected identifier %q", m.Identifier, "k8s:required")
182+
}
183+
184+
payload, ok := m.Expressions["payload"]
185+
if !ok {
186+
return errors.New("expected a payload but there was none")
187+
}
188+
189+
if payload != "10" {
190+
return fmt.Errorf("payload value of %q does not match expected value %q", payload, "10")
191+
}
192+
193+
return nil
194+
},
195+
},
196+
{
197+
name: "declarative validation marker with named argument",
198+
comment: &ast.Comment{
199+
Text: "// +k8s:unionMember(union: \"union1\")",
200+
},
201+
expect: func(m Marker) error {
202+
if m.Identifier != "k8s:unionMember" {
203+
return fmt.Errorf("identifier %q did not match expected identifier %q", m.Identifier, "k8s:unionMember")
204+
}
205+
206+
union, ok := m.Expressions["union"]
207+
if !ok {
208+
return fmt.Errorf("expected named argument %q to be in expressions but it was missing", "union")
209+
}
210+
211+
if union != "union1" {
212+
return fmt.Errorf("union value of %q does not match expected value %q", union, "union1")
213+
}
214+
215+
return nil
216+
},
217+
},
218+
{
219+
name: "declarative validation marker with unnamed argument",
220+
comment: &ast.Comment{
221+
Text: "// +k8s:doesWork(100)", // not a real DV marker, but AFAIK nothing stops something like this from coming up
222+
},
223+
expect: func(m Marker) error {
224+
if m.Identifier != "k8s:doesWork" {
225+
return fmt.Errorf("identifier %q did not match expected identifier %q", m.Identifier, "k8s:doesWork")
226+
}
227+
228+
unnamed, ok := m.Expressions[""]
229+
if !ok {
230+
return errors.New("expected unnamed argument to be in expressions but it was missing")
231+
}
232+
233+
if unnamed != "100" {
234+
return fmt.Errorf("unnamed argument value of %q does not match expected value %q", unnamed, "100")
235+
}
236+
237+
return nil
238+
},
239+
},
240+
{
241+
name: "declarative validation marker with unnamed argument and simple validation tag payload",
242+
comment: &ast.Comment{
243+
Text: "// +k8s:ifEnabled(\"my-feature\")=+k8s:required",
244+
},
245+
expect: func(m Marker) error {
246+
if m.Identifier != "k8s:ifEnabled" {
247+
return fmt.Errorf("identifier %q did not match expected identifier %q", m.Identifier, "k8s:ifEnabled")
248+
}
249+
250+
unnamed, ok := m.Expressions[""]
251+
if !ok {
252+
return errors.New("expected unnamed argument to be in expressions but it was missing")
253+
}
254+
255+
if unnamed != "my-feature" {
256+
return fmt.Errorf("unnamed argument value of %q does not match expected value %q", unnamed, "my-feature")
257+
}
258+
259+
if m.Marker == nil {
260+
return errors.New("expected a nested marker but none was found")
261+
}
262+
263+
if m.Marker.Identifier != "k8s:required" {
264+
return fmt.Errorf("nested marker identifier %q does not match expected identifier %q", m.Marker.Identifier, "k8s:required")
265+
}
266+
267+
return nil
268+
},
269+
},
270+
{
271+
name: "declarative validation marker with deeper chained validation tags",
272+
comment: &ast.Comment{
273+
Text: "// +k8s:ifEnabled(\"my-feature\")=+k8s:item(type: \"Approved\")=+k8s:zeroOrOneOfMember",
274+
},
275+
expect: func(m Marker) error {
276+
if m.Identifier != "k8s:ifEnabled" {
277+
return fmt.Errorf("identifier %q did not match expected identifier %q", m.Identifier, "k8s:ifEnabled")
278+
}
279+
280+
unnamed, ok := m.Expressions[""]
281+
if !ok {
282+
return errors.New("expected unnamed argument to be in expressions but it was missing")
283+
}
284+
285+
if unnamed != "my-feature" {
286+
return fmt.Errorf("unnamed argument value of %q does not match expected value %q", unnamed, "my-feature")
287+
}
288+
289+
if m.Marker == nil {
290+
return errors.New("expected a nested marker but none was found")
291+
}
292+
293+
if m.Marker.Identifier != "k8s:item" {
294+
return fmt.Errorf("nested marker identifier %q does not match expected identifier %q", m.Marker.Identifier, "k8s:item")
295+
}
296+
297+
typ, ok := m.Marker.Expressions["type"]
298+
if !ok {
299+
return errors.New("expected nested marker to have named argument \"type\" but it was missing")
300+
}
301+
302+
if typ != "Approved" {
303+
return fmt.Errorf("nested marker named argument \"type\" value of %q does not match expected value %q", typ, "Approved")
304+
}
305+
306+
if m.Marker.Marker == nil {
307+
return errors.New("expected nested marker to have a nested marker but none was found")
308+
}
309+
310+
if m.Marker.Marker.Identifier != "k8s:zeroOrOneOfMember" {
311+
return fmt.Errorf("nested marker's nested marker identifier %q does not match expected identifier %q", m.Marker.Identifier, "k8s:zeroOrOneOfMember")
312+
}
313+
314+
return nil
315+
},
316+
},
317+
}
318+
319+
for _, tc := range testcases {
320+
t.Run(tc.name, func(t *testing.T) {
321+
g := NewWithT(t)
322+
323+
marker, err := extractMarker(tc.comment)
324+
g.Expect(err).NotTo(HaveOccurred())
325+
g.Expect(tc.expect(marker)).NotTo(HaveOccurred())
326+
})
327+
}
328+
}

0 commit comments

Comments
 (0)