Skip to content

Commit 3e58f96

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

File tree

11 files changed

+558
-225
lines changed

11 files changed

+558
-225
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: 172 additions & 37 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.
@@ -163,7 +168,8 @@ func extractGenDeclMarkers(typ *ast.GenDecl, results *markers) {
163168

164169
if typ.Doc != nil {
165170
for _, comment := range typ.Doc.List {
166-
if marker := extractMarker(comment); marker.Identifier != "" {
171+
marker := extractMarker(comment)
172+
if marker.Identifier != "" {
167173
declMarkers.Insert(marker)
168174
}
169175
}
@@ -193,7 +199,8 @@ func extractFieldMarkers(field *ast.Field, results *markers) {
193199
fieldMarkers := NewMarkerSet()
194200

195201
for _, comment := range field.Doc.List {
196-
if marker := extractMarker(comment); marker.Identifier != "" {
202+
marker := extractMarker(comment)
203+
if marker.Identifier != "" {
197204
fieldMarkers.Insert(marker)
198205
}
199206
}
@@ -221,34 +228,98 @@ func extractMarker(comment *ast.Comment) Marker {
221228
return Marker{}
222229
}
223230

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

226246
return Marker{
227-
Identifier: id,
228-
Expressions: expressions,
229-
RawComment: comment.Text,
230-
Pos: comment.Pos(),
231-
End: comment.End(),
247+
Type: MarkerTypeKubebuilder,
248+
Identifier: id,
249+
Arguments: arguments,
250+
Payload: payload,
251+
RawComment: comment.Text,
252+
Pos: comment.Pos(),
253+
End: comment.End(),
232254
}
233255
}
234256

235-
func extractMarkerIDAndExpressions(knownMarkers Registry, marker string) (string, map[string]string) {
257+
func extractMarkerIDArgumentsAndPayload(knownMarkers Registry, marker string) (string, map[string]string, *Payload) {
236258
if id, ok := knownMarkers.Match(marker); ok {
237-
return extractKnownMarkerIDAndExpressions(id, marker)
259+
return extractKnownMarkerIDArgumentsAndPayload(id, marker)
260+
}
261+
262+
return extractUnknownMarkerIDArgumentsAndPayload(marker)
263+
}
264+
265+
func isDeclarativeValidationMarker(marker string) bool {
266+
return strings.HasPrefix(marker, "k8s:")
267+
}
268+
269+
func extractDeclarativeValidationMarker(marker string, comment *ast.Comment) *Marker {
270+
tag, err := codetags.Parse(marker)
271+
if err != nil {
272+
return nil
238273
}
239274

240-
return extractUnknownMarkerIDAndExpressions(marker)
275+
return markerForTag(&tag, comment)
241276
}
242277

243-
func extractKnownMarkerIDAndExpressions(id string, marker string) (string, map[string]string) {
244-
return id, extractExpressions(strings.TrimPrefix(marker, id))
278+
func markerForTag(tag *codetags.Tag, comment *ast.Comment) *Marker {
279+
out := &Marker{
280+
Type: MarkerTypeDeclarativeValidation,
281+
Identifier: tag.Name,
282+
Arguments: make(map[string]string),
283+
RawComment: comment.Text,
284+
Pos: comment.Pos(),
285+
End: comment.End(),
286+
}
287+
288+
for _, arg := range tag.Args {
289+
out.Arguments[arg.Name] = arg.Value
290+
}
291+
292+
switch tag.ValueType {
293+
case codetags.ValueTypeString, codetags.ValueTypeInt, codetags.ValueTypeBool, codetags.ValueTypeRaw:
294+
// all resolvable to an exact string value
295+
out.Payload = &Payload{
296+
Value: tag.Value,
297+
}
298+
case codetags.ValueTypeNone:
299+
// nothing
300+
case codetags.ValueTypeTag:
301+
out.Payload = &Payload{
302+
Marker: markerForTag(tag.ValueTag, comment),
303+
}
304+
default:
305+
return nil
306+
}
307+
308+
return out
309+
}
310+
311+
func extractKnownMarkerIDArgumentsAndPayload(id string, marker string) (string, map[string]string, *Payload) {
312+
args, payload := extractArgumentsAndPayload(strings.TrimPrefix(marker, id))
313+
return id, args, payload
245314
}
246315

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

249-
func extractExpressions(expressionStr string) map[string]string {
318+
func extractArgumentsAndPayload(expressionStr string) (map[string]string, *Payload) {
250319
expressionsMap := map[string]string{}
251320

321+
var payload *Payload
322+
252323
// Do some normalization work to ensure we can parse expressions in
253324
// a standard way. Trim any lingering colons (:) and replace all ':='s with '='
254325
expressionStr = strings.TrimPrefix(expressionStr, ":")
@@ -261,13 +332,21 @@ func extractExpressions(expressionStr string) map[string]string {
261332
continue
262333
}
263334

335+
if key == UnnamedArgument {
336+
payload = &Payload{
337+
Value: value,
338+
}
339+
340+
continue
341+
}
342+
264343
expressionsMap[key] = value
265344
}
266345

267-
return expressionsMap
346+
return expressionsMap, payload
268347
}
269348

270-
func extractUnknownMarkerIDAndExpressions(marker string) (string, map[string]string) {
349+
func extractUnknownMarkerIDArgumentsAndPayload(marker string) (string, map[string]string, *Payload) {
271350
// if there is only a single "=" split on the equal sign and trim any
272351
// dangling ":" characters.
273352
if strings.Count(marker, "=") == 1 {
@@ -277,10 +356,8 @@ func extractUnknownMarkerIDAndExpressions(marker string) (string, map[string]str
277356
identifier := strings.TrimSuffix(splits[0], ":")
278357

279358
// 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
359+
// marker is the identifier and there is no real argument identifier.
360+
return identifier, make(map[string]string), &Payload{Value: splits[1]}
284361
}
285362

286363
// split on :
@@ -315,18 +392,65 @@ func extractUnknownMarkerIDAndExpressions(marker string) (string, map[string]str
315392
expressionString = strings.Join([]string{expressionString, item}, ",")
316393
}
317394

318-
expressions := extractExpressions(expressionString)
395+
expressions, payload := extractArgumentsAndPayload(expressionString)
319396

320-
return identifier, expressions
397+
return identifier, expressions, payload
398+
}
399+
400+
// MarkerType is a representation of the style of marker.
401+
// Currently can be one of Kubebuilder or DeclarativeValidation.
402+
type MarkerType string
403+
404+
const (
405+
// MarkerTypeKubebuilder represents a Kubebuilder-style marker.
406+
MarkerTypeKubebuilder MarkerType = "Kubebuilder"
407+
// MarkerTypeDeclarativeValidation represents a Declarative Validation marker.
408+
MarkerTypeDeclarativeValidation MarkerType = "DeclarativeValidation"
409+
)
410+
411+
// Payload represents the payload of a marker.
412+
type Payload struct {
413+
// Value is the payload value of a marker represented as a string.
414+
// Value is set when the payload value of a marker is not another marker.
415+
Value string
416+
417+
// Marker is the marker in the payload value of another marker.
418+
// Marker is only set when the payload value of a marker is another marker.
419+
Marker *Marker
321420
}
322421

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

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

331455
// RawComment is the raw comment line, unfiltered.
332456
RawComment string
@@ -377,22 +501,33 @@ func (ms MarkerSet) Has(identifier string) bool {
377501
}
378502

379503
// HasWithValue returns whether marker(s) with the given identifier and
380-
// expression values (i.e "kubebuilder:object:root:=true") is present
504+
// argument/payload values (i.e "kubebuilder:object:root:=true") is present
381505
// in the MarkerSet.
382506
func (ms MarkerSet) HasWithValue(marker string) bool {
383-
return ms.HasWithExpressions(extractMarkerIDAndExpressions(DefaultRegistry(), marker))
507+
if isDeclarativeValidationMarker(marker) {
508+
marker := extractDeclarativeValidationMarker(marker, &ast.Comment{})
509+
if marker == nil {
510+
return false
511+
}
512+
513+
return ms.HasWithArgumentsAndPayload(marker.Identifier, marker.Arguments, marker.Payload)
514+
}
515+
516+
id, args, payload := extractMarkerIDArgumentsAndPayload(DefaultRegistry(), marker)
517+
518+
return ms.HasWithArgumentsAndPayload(id, args, payload)
384519
}
385520

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 {
521+
// HasWithArgumentsAndPayload returns whether marker(s) with the
522+
// identifier, arguments, and payload are present in the MarkerSet.
523+
func (ms MarkerSet) HasWithArgumentsAndPayload(identifier string, arguments map[string]string, payload *Payload) bool {
389524
markers, ok := ms[identifier]
390525
if !ok {
391526
return false
392527
}
393528

394529
for _, marker := range markers {
395-
if reflect.DeepEqual(marker.Expressions, expressions) {
530+
if reflect.DeepEqual(marker.Arguments, arguments) && reflect.DeepEqual(marker.Payload, payload) {
396531
return true
397532
}
398533
}

0 commit comments

Comments
 (0)