Skip to content

Commit ec9df7c

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

File tree

11 files changed

+563
-224
lines changed

11 files changed

+563
-224
lines changed

go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ require (
99
github.com/onsi/gomega v1.38.0
1010
golang.org/x/tools v0.37.0
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
sigs.k8s.io/yaml v1.4.0
1415
)

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -993,6 +993,8 @@ honnef.co/go/tools v0.6.1 h1:R094WgE8K4JirYjBaOpz/AvTyUu/3wbmAoskKN/pxTI=
993993
honnef.co/go/tools v0.6.1/go.mod h1:3puzxxljPCe8RGJX7BIy1plGbxEOZni5mR2aXe3/uk4=
994994
k8s.io/apimachinery v0.32.3 h1:JmDuDarhDmA/Li7j3aPrwhpNBA94Nvk5zLeOge9HH1U=
995995
k8s.io/apimachinery v0.32.3/go.mod h1:GpHVgxoKlTxClKcteaeuF1Ul/lDVb74KpZcxcmLDElE=
996+
k8s.io/gengo/v2 v2.0.0-20250922181213-ec3ebc5fd46b h1:gMplByicHV/TJBizHd9aVEsTYoJBnnUAT5MHlTkbjhQ=
997+
k8s.io/gengo/v2 v2.0.0-20250922181213-ec3ebc5fd46b/go.mod h1:CgujABENc3KuTrcsdpGmrrASjtQsWCT7R99mEV4U/fM=
996998
k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 h1:M3sRQVHv7vB20Xc2ybTt7ODCeFj6JSWYFzOFnYeS6Ro=
997999
k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0=
9981000
mvdan.cc/gofumpt v0.9.1 h1:p5YT2NfFWsYyTieYgwcQ8aKV3xRvFH4uuN/zB2gBbMQ=

pkg/analysis/forbiddenmarkers/analyzer.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,7 @@ func markerMatchesAttributeRules(marker markers.Marker, attrRules ...MarkerAttri
112112
for _, attrRule := range attrRules {
113113
// if the marker doesn't contain the attribute for a specified rule it fails the AND
114114
// operation.
115-
val, ok := marker.Expressions[attrRule.Name]
115+
val, ok := marker.Arguments[attrRule.Name]
116116
if !ok {
117117
return false
118118
}

pkg/analysis/helpers/markers/analyzer.go

Lines changed: 167 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -26,19 +26,24 @@ import (
2626
"golang.org/x/tools/go/analysis/passes/inspect"
2727
"golang.org/x/tools/go/ast/inspector"
2828

29+
"k8s.io/gengo/v2/codetags"
30+
2931
kalerrors "sigs.k8s.io/kube-api-linter/pkg/analysis/errors"
3032
)
3133

32-
// UnnamedExpression is the expression key used
34+
// UnnamedArgument is the argument key used
3335
// when parsing markers that don't have a specific
34-
// named expression.
36+
// named argument.
37+
//
38+
// This is specific to declarative validation markers only.
39+
// Kubebuilder-style markers either have named arguments or a payload.
3540
//
36-
// An example of a marker without a named expression
37-
// is "kubebuilder:default:=foo".
41+
// An example of a Declarative Validation marker with an unnamed argument
42+
// is "k8s:ifEnabled(\"my-feature\")=...".
3843
//
39-
// An example of a marker with named expressions
40-
// is "kubebuilder:validation:XValidation:rule='...',message='...'".
41-
const UnnamedExpression = ""
44+
// An example of a Declarative Validation marker with named arguments
45+
// is "k8s:item(one: "value", two: "value")=...".
46+
const UnnamedArgument = ""
4247

4348
// Markers allows access to markers extracted from the
4449
// go types.
@@ -193,7 +198,8 @@ func extractFieldMarkers(field *ast.Field, results *markers) {
193198
fieldMarkers := NewMarkerSet()
194199

195200
for _, comment := range field.Doc.List {
196-
if marker := extractMarker(comment); marker.Identifier != "" {
201+
marker := extractMarker(comment)
202+
if marker.Identifier != "" {
197203
fieldMarkers.Insert(marker)
198204
}
199205
}
@@ -221,34 +227,98 @@ func extractMarker(comment *ast.Comment) Marker {
221227
return Marker{}
222228
}
223229

224-
id, expressions := extractMarkerIDAndExpressions(DefaultRegistry(), markerContent)
230+
if isDeclarativeValidationMarker(markerContent) {
231+
marker := extractDeclarativeValidationMarker(markerContent, comment)
232+
if marker == nil {
233+
return Marker{}
234+
}
235+
236+
return *marker
237+
}
238+
239+
return extractKubebuilderMarker(markerContent, comment)
240+
}
241+
242+
func extractKubebuilderMarker(markerContent string, comment *ast.Comment) Marker {
243+
id, arguments, payload := extractMarkerIDArgumentsAndPayload(DefaultRegistry(), markerContent)
225244

226245
return Marker{
227-
Identifier: id,
228-
Expressions: expressions,
229-
RawComment: comment.Text,
230-
Pos: comment.Pos(),
231-
End: comment.End(),
246+
Type: MarkerTypeKubebuilder,
247+
Identifier: id,
248+
Arguments: arguments,
249+
Payload: payload,
250+
RawComment: comment.Text,
251+
Pos: comment.Pos(),
252+
End: comment.End(),
232253
}
233254
}
234255

235-
func extractMarkerIDAndExpressions(knownMarkers Registry, marker string) (string, map[string]string) {
256+
func extractMarkerIDArgumentsAndPayload(knownMarkers Registry, marker string) (string, map[string]string, Payload) {
236257
if id, ok := knownMarkers.Match(marker); ok {
237-
return extractKnownMarkerIDAndExpressions(id, marker)
258+
return extractKnownMarkerIDArgumentsAndPayload(id, marker)
238259
}
239260

240-
return extractUnknownMarkerIDAndExpressions(marker)
261+
return extractUnknownMarkerIDArgumentsAndPayload(marker)
241262
}
242263

243-
func extractKnownMarkerIDAndExpressions(id string, marker string) (string, map[string]string) {
244-
return id, extractExpressions(strings.TrimPrefix(marker, id))
264+
func isDeclarativeValidationMarker(marker string) bool {
265+
return strings.HasPrefix(marker, "k8s:")
266+
}
267+
268+
func extractDeclarativeValidationMarker(marker string, comment *ast.Comment) *Marker {
269+
tag, err := codetags.Parse(marker)
270+
if err != nil {
271+
return nil
272+
}
273+
274+
return markerForTag(tag, comment)
275+
}
276+
277+
func markerForTag(tag codetags.Tag, comment *ast.Comment) *Marker {
278+
out := &Marker{
279+
Type: MarkerTypeDeclarativeValidation,
280+
Identifier: tag.Name,
281+
Arguments: make(map[string]string),
282+
RawComment: comment.Text,
283+
Pos: comment.Pos(),
284+
End: comment.End(),
285+
}
286+
287+
for _, arg := range tag.Args {
288+
out.Arguments[arg.Name] = arg.Value
289+
}
290+
291+
switch tag.ValueType {
292+
case codetags.ValueTypeString, codetags.ValueTypeInt, codetags.ValueTypeBool, codetags.ValueTypeRaw:
293+
// all resolvable to an exact string value
294+
out.Payload = Payload{
295+
Value: tag.Value,
296+
}
297+
case codetags.ValueTypeNone:
298+
// nothing
299+
case codetags.ValueTypeTag:
300+
out.Payload = Payload{
301+
Marker: markerForTag(*tag.ValueTag, comment),
302+
}
303+
default:
304+
return nil
305+
}
306+
307+
return out
308+
}
309+
310+
func extractKnownMarkerIDArgumentsAndPayload(id string, marker string) (string, map[string]string, Payload) {
311+
args, payload := extractArgumentsAndPayload(strings.TrimPrefix(marker, id))
312+
return id, args, payload
245313
}
246314

247315
var expressionRegex = regexp.MustCompile("\\w*=(?:'[^']*'|\"(\\\\\"|[^\"])*\"|[\\w;\\-\"]+|`[^`]*`)")
248316

249-
func extractExpressions(expressionStr string) map[string]string {
317+
func extractArgumentsAndPayload(expressionStr string) (map[string]string, Payload) {
250318
expressionsMap := map[string]string{}
251319

320+
var payload Payload
321+
252322
// Do some normalization work to ensure we can parse expressions in
253323
// a standard way. Trim any lingering colons (:) and replace all ':='s with '='
254324
expressionStr = strings.TrimPrefix(expressionStr, ":")
@@ -261,13 +331,18 @@ func extractExpressions(expressionStr string) map[string]string {
261331
continue
262332
}
263333

334+
if key == UnnamedArgument {
335+
payload.Value = value
336+
continue
337+
}
338+
264339
expressionsMap[key] = value
265340
}
266341

267-
return expressionsMap
342+
return expressionsMap, payload
268343
}
269344

270-
func extractUnknownMarkerIDAndExpressions(marker string) (string, map[string]string) {
345+
func extractUnknownMarkerIDArgumentsAndPayload(marker string) (string, map[string]string, Payload) {
271346
// if there is only a single "=" split on the equal sign and trim any
272347
// dangling ":" characters.
273348
if strings.Count(marker, "=") == 1 {
@@ -277,10 +352,8 @@ func extractUnknownMarkerIDAndExpressions(marker string) (string, map[string]str
277352
identifier := strings.TrimSuffix(splits[0], ":")
278353

279354
// If there is a single "=" sign that means the left side of the
280-
// marker is the identifier and there is no real expression identifier.
281-
expressions := map[string]string{UnnamedExpression: splits[1]}
282-
283-
return identifier, expressions
355+
// marker is the identifier and there is no real argument identifier.
356+
return identifier, make(map[string]string), Payload{Value: splits[1]}
284357
}
285358

286359
// split on :
@@ -315,18 +388,65 @@ func extractUnknownMarkerIDAndExpressions(marker string) (string, map[string]str
315388
expressionString = strings.Join([]string{expressionString, item}, ",")
316389
}
317390

318-
expressions := extractExpressions(expressionString)
391+
expressions, payload := extractArgumentsAndPayload(expressionString)
392+
393+
return identifier, expressions, payload
394+
}
395+
396+
// MarkerType is a representation of the style of marker.
397+
// Currently can be one of Kubebuilder or DeclarativeValidation.
398+
type MarkerType string
399+
400+
const (
401+
// MarkerTypeKubebuilder represents a Kubebuilder-style marker.
402+
MarkerTypeKubebuilder MarkerType = "Kubebuilder"
403+
// MarkerTypeDeclarativeValidation represents a Declarative Validation marker.
404+
MarkerTypeDeclarativeValidation MarkerType = "DeclarativeValidation"
405+
)
319406

320-
return identifier, expressions
407+
// Payload represents the payload of a marker.
408+
type Payload struct {
409+
// Value is the payload value of a marker represented as a string.
410+
// Value is set when the payload value of a marker is not another marker.
411+
Value string
412+
413+
// Marker is the marker in the payload value of another marker.
414+
// Marker is only set when the payload value of a marker is another marker.
415+
Marker *Marker
321416
}
322417

323418
// Marker represents a marker extracted from a comment on a declaration.
324419
type Marker struct {
420+
// Type is the marker representation this marker was identified as.
421+
// Currently, the two marker format types are DeclarativeValidation and Kubebuilder.
422+
// Because the Kubebuilder style has been around the longest and is widely
423+
// used in projects that have CustomResourceDefinitions we default to Kubebuilder
424+
// style parsing unless we detect that the marker follows the declarative validation
425+
// format (i.e begins with +k8s:).
426+
Type MarkerType
427+
325428
// Identifier is the value of the marker once the leading comment, '+', and expressions are trimmed.
326429
Identifier string
327430

328-
// Expressions are the set of expressions that have been specified for the marker
329-
Expressions map[string]string
431+
// Arguments are the set of named and unnamed arguments that have been specified for the marker.
432+
//
433+
// For Markers with Type == Kubebuilder, there will only ever be named arguments. The following examples highlight how arguments are extracted:
434+
// - `+kubebuilder:validation:Required` would result in *no* arguments.
435+
// - `+required` would result in *no* arguments.
436+
// - `+kubebuilder:validation:MinLength=10` would result in no arguments`.
437+
// - `+kubebuilder:validation:XValidation:rule="has(self)",message="should have self"` would result in 2 named arguments, `rule` and `message` with their respective values in string representation.
438+
//
439+
// For Markers with Type == DeclarativeValidation, arguments are extracted from the marker parameters. Arguments may be named or unnamed.
440+
// Some examples:
441+
// - `+k8s:forbidden` would result in *no* arguments.
442+
// - `+k8s:ifEnabled("my-feature")=...` would result in a single unnamed argument (represented by key `""`) with a value of `"my-feature"`.
443+
// - `+k8s:item(one: "value", two: "value")=...` would result in 2 named arguments, `one` and `two` with their respective values in string representation.
444+
Arguments map[string]string
445+
446+
// Payload is the payload specified by the marker.
447+
// In general, it is what is present after the first `=` symbol
448+
// of a marker.
449+
Payload Payload
330450

331451
// RawComment is the raw comment line, unfiltered.
332452
RawComment string
@@ -377,22 +497,33 @@ func (ms MarkerSet) Has(identifier string) bool {
377497
}
378498

379499
// HasWithValue returns whether marker(s) with the given identifier and
380-
// expression values (i.e "kubebuilder:object:root:=true") is present
500+
// argument/payload values (i.e "kubebuilder:object:root:=true") is present
381501
// in the MarkerSet.
382502
func (ms MarkerSet) HasWithValue(marker string) bool {
383-
return ms.HasWithExpressions(extractMarkerIDAndExpressions(DefaultRegistry(), marker))
503+
if isDeclarativeValidationMarker(marker) {
504+
marker := extractDeclarativeValidationMarker(marker, &ast.Comment{})
505+
if marker == nil {
506+
return false
507+
}
508+
509+
return ms.HasWithArgumentsAndPayload(marker.Identifier, marker.Arguments, marker.Payload)
510+
}
511+
512+
id, args, payload := extractMarkerIDArgumentsAndPayload(DefaultRegistry(), marker)
513+
514+
return ms.HasWithArgumentsAndPayload(id, args, payload)
384515
}
385516

386-
// HasWithExpressions returns whether marker(s) with the identifier and
387-
// expressions are present in the MarkerSet.
388-
func (ms MarkerSet) HasWithExpressions(identifier string, expressions map[string]string) bool {
517+
// HasWithArgumentsAndPayload returns whether marker(s) with the
518+
// identifier, arguments, and payload are present in the MarkerSet.
519+
func (ms MarkerSet) HasWithArgumentsAndPayload(identifier string, arguments map[string]string, payload Payload) bool {
389520
markers, ok := ms[identifier]
390521
if !ok {
391522
return false
392523
}
393524

394525
for _, marker := range markers {
395-
if reflect.DeepEqual(marker.Expressions, expressions) {
526+
if reflect.DeepEqual(marker.Arguments, arguments) && reflect.DeepEqual(marker.Payload, payload) {
396527
return true
397528
}
398529
}

0 commit comments

Comments
 (0)