Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ require (
github.com/onsi/gomega v1.38.0
golang.org/x/tools v0.37.0
k8s.io/apimachinery v0.32.3
k8s.io/gengo/v2 v2.0.0-20250922181213-ec3ebc5fd46b
k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738
sigs.k8s.io/yaml v1.4.0
)
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -993,6 +993,8 @@ honnef.co/go/tools v0.6.1 h1:R094WgE8K4JirYjBaOpz/AvTyUu/3wbmAoskKN/pxTI=
honnef.co/go/tools v0.6.1/go.mod h1:3puzxxljPCe8RGJX7BIy1plGbxEOZni5mR2aXe3/uk4=
k8s.io/apimachinery v0.32.3 h1:JmDuDarhDmA/Li7j3aPrwhpNBA94Nvk5zLeOge9HH1U=
k8s.io/apimachinery v0.32.3/go.mod h1:GpHVgxoKlTxClKcteaeuF1Ul/lDVb74KpZcxcmLDElE=
k8s.io/gengo/v2 v2.0.0-20250922181213-ec3ebc5fd46b h1:gMplByicHV/TJBizHd9aVEsTYoJBnnUAT5MHlTkbjhQ=
k8s.io/gengo/v2 v2.0.0-20250922181213-ec3ebc5fd46b/go.mod h1:CgujABENc3KuTrcsdpGmrrASjtQsWCT7R99mEV4U/fM=
k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 h1:M3sRQVHv7vB20Xc2ybTt7ODCeFj6JSWYFzOFnYeS6Ro=
k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0=
mvdan.cc/gofumpt v0.9.1 h1:p5YT2NfFWsYyTieYgwcQ8aKV3xRvFH4uuN/zB2gBbMQ=
Expand Down
2 changes: 1 addition & 1 deletion pkg/analysis/forbiddenmarkers/analyzer.go
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ func markerMatchesAttributeRules(marker markers.Marker, attrRules ...MarkerAttri
for _, attrRule := range attrRules {
// if the marker doesn't contain the attribute for a specified rule it fails the AND
// operation.
val, ok := marker.Expressions[attrRule.Name]
val, ok := marker.Arguments[attrRule.Name]
if !ok {
return false
}
Expand Down
203 changes: 167 additions & 36 deletions pkg/analysis/helpers/markers/analyzer.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,19 +26,24 @@ import (
"golang.org/x/tools/go/analysis/passes/inspect"
"golang.org/x/tools/go/ast/inspector"

"k8s.io/gengo/v2/codetags"

kalerrors "sigs.k8s.io/kube-api-linter/pkg/analysis/errors"
)

// UnnamedExpression is the expression key used
// UnnamedArgument is the argument key used
// when parsing markers that don't have a specific
// named expression.
// named argument.
//
// This is specific to declarative validation markers only.
// Kubebuilder-style markers either have named arguments or a payload.
//
// An example of a marker without a named expression
// is "kubebuilder:default:=foo".
// An example of a Declarative Validation marker with an unnamed argument
// is "k8s:ifEnabled(\"my-feature\")=...".
//
// An example of a marker with named expressions
// is "kubebuilder:validation:XValidation:rule='...',message='...'".
const UnnamedExpression = ""
// An example of a Declarative Validation marker with named arguments
// is "k8s:item(one: "value", two: "value")=...".
const UnnamedArgument = ""

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

for _, comment := range field.Doc.List {
if marker := extractMarker(comment); marker.Identifier != "" {
marker := extractMarker(comment)
if marker.Identifier != "" {
fieldMarkers.Insert(marker)
}
}
Expand Down Expand Up @@ -221,34 +227,98 @@ func extractMarker(comment *ast.Comment) Marker {
return Marker{}
}

id, expressions := extractMarkerIDAndExpressions(DefaultRegistry(), markerContent)
if isDeclarativeValidationMarker(markerContent) {
marker := extractDeclarativeValidationMarker(markerContent, comment)
if marker == nil {
return Marker{}
}

return *marker
}

return extractKubebuilderMarker(markerContent, comment)
}

func extractKubebuilderMarker(markerContent string, comment *ast.Comment) Marker {
id, arguments, payload := extractMarkerIDArgumentsAndPayload(DefaultRegistry(), markerContent)

return Marker{
Identifier: id,
Expressions: expressions,
RawComment: comment.Text,
Pos: comment.Pos(),
End: comment.End(),
Type: MarkerTypeKubebuilder,
Identifier: id,
Arguments: arguments,
Payload: payload,
RawComment: comment.Text,
Pos: comment.Pos(),
End: comment.End(),
}
}

func extractMarkerIDAndExpressions(knownMarkers Registry, marker string) (string, map[string]string) {
func extractMarkerIDArgumentsAndPayload(knownMarkers Registry, marker string) (string, map[string]string, Payload) {
if id, ok := knownMarkers.Match(marker); ok {
return extractKnownMarkerIDAndExpressions(id, marker)
return extractKnownMarkerIDArgumentsAndPayload(id, marker)
}

return extractUnknownMarkerIDAndExpressions(marker)
return extractUnknownMarkerIDArgumentsAndPayload(marker)
}

func extractKnownMarkerIDAndExpressions(id string, marker string) (string, map[string]string) {
return id, extractExpressions(strings.TrimPrefix(marker, id))
func isDeclarativeValidationMarker(marker string) bool {
return strings.HasPrefix(marker, "k8s:")
}

func extractDeclarativeValidationMarker(marker string, comment *ast.Comment) *Marker {
tag, err := codetags.Parse(marker)
if err != nil {
return nil
}

return markerForTag(tag, comment)
}

func markerForTag(tag codetags.Tag, comment *ast.Comment) *Marker {
out := &Marker{
Type: MarkerTypeDeclarativeValidation,
Identifier: tag.Name,
Arguments: make(map[string]string),
RawComment: comment.Text,
Pos: comment.Pos(),
End: comment.End(),
}

for _, arg := range tag.Args {
out.Arguments[arg.Name] = arg.Value
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Positional arguments, of which only one is allowed in a DV tag, the argument name will be "" which aligns with our UnnamedArgument constant.

}

switch tag.ValueType {
case codetags.ValueTypeString, codetags.ValueTypeInt, codetags.ValueTypeBool, codetags.ValueTypeRaw:
// all resolvable to an exact string value
out.Payload = Payload{
Value: tag.Value,
}
case codetags.ValueTypeNone:
// nothing
case codetags.ValueTypeTag:
out.Payload = Payload{
Marker: markerForTag(*tag.ValueTag, comment),
}
default:
return nil
}

return out
}

func extractKnownMarkerIDArgumentsAndPayload(id string, marker string) (string, map[string]string, Payload) {
args, payload := extractArgumentsAndPayload(strings.TrimPrefix(marker, id))
return id, args, payload
}

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

func extractExpressions(expressionStr string) map[string]string {
func extractArgumentsAndPayload(expressionStr string) (map[string]string, Payload) {
expressionsMap := map[string]string{}

var payload Payload

// Do some normalization work to ensure we can parse expressions in
// a standard way. Trim any lingering colons (:) and replace all ':='s with '='
expressionStr = strings.TrimPrefix(expressionStr, ":")
Expand All @@ -261,13 +331,18 @@ func extractExpressions(expressionStr string) map[string]string {
continue
}

if key == UnnamedArgument {
payload.Value = value
continue
}

expressionsMap[key] = value
}

return expressionsMap
return expressionsMap, payload
}

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

// If there is a single "=" sign that means the left side of the
// marker is the identifier and there is no real expression identifier.
expressions := map[string]string{UnnamedExpression: splits[1]}

return identifier, expressions
// marker is the identifier and there is no real argument identifier.
return identifier, make(map[string]string), Payload{Value: splits[1]}
}

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

expressions := extractExpressions(expressionString)
expressions, payload := extractArgumentsAndPayload(expressionString)

return identifier, expressions, payload
}

// MarkerType is a representation of the style of marker.
// Currently can be one of Kubebuilder or DeclarativeValidation.
type MarkerType string

const (
// MarkerTypeKubebuilder represents a Kubebuilder-style marker.
MarkerTypeKubebuilder MarkerType = "Kubebuilder"
// MarkerTypeDeclarativeValidation represents a Declarative Validation marker.
MarkerTypeDeclarativeValidation MarkerType = "DeclarativeValidation"
)

return identifier, expressions
// Payload represents the payload of a marker.
type Payload struct {
// Value is the payload value of a marker represented as a string.
// Value is set when the payload value of a marker is not another marker.
Value string

// Marker is the marker in the payload value of another marker.
// Marker is only set when the payload value of a marker is another marker.
Marker *Marker
}

// Marker represents a marker extracted from a comment on a declaration.
type Marker struct {
// Type is the marker representation this marker was identified as.
// Currently, the two marker format types are DeclarativeValidation and Kubebuilder.
// Because the Kubebuilder style has been around the longest and is widely
// used in projects that have CustomResourceDefinitions we default to Kubebuilder
// style parsing unless we detect that the marker follows the declarative validation
// format (i.e begins with +k8s:).
Type MarkerType

// Identifier is the value of the marker once the leading comment, '+', and expressions are trimmed.
Identifier string

// Expressions are the set of expressions that have been specified for the marker
Expressions map[string]string
// Arguments are the set of named and unnamed arguments that have been specified for the marker.
//
// For Markers with Type == Kubebuilder, there will only ever be named arguments. The following examples highlight how arguments are extracted:
// - `+kubebuilder:validation:Required` would result in *no* arguments.
// - `+required` would result in *no* arguments.
// - `+kubebuilder:validation:MinLength=10` would result in no arguments`.
// - `+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.
//
// For Markers with Type == DeclarativeValidation, arguments are extracted from the marker parameters. Arguments may be named or unnamed.
// Some examples:
// - `+k8s:forbidden` would result in *no* arguments.
// - `+k8s:ifEnabled("my-feature")=...` would result in a single unnamed argument (represented by key `""`) with a value of `"my-feature"`.
// - `+k8s:item(one: "value", two: "value")=...` would result in 2 named arguments, `one` and `two` with their respective values in string representation.
Arguments map[string]string

// Payload is the payload specified by the marker.
// In general, it is what is present after the first `=` symbol
// of a marker.
Payload Payload

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

// HasWithValue returns whether marker(s) with the given identifier and
// expression values (i.e "kubebuilder:object:root:=true") is present
// argument/payload values (i.e "kubebuilder:object:root:=true") is present
// in the MarkerSet.
func (ms MarkerSet) HasWithValue(marker string) bool {
return ms.HasWithExpressions(extractMarkerIDAndExpressions(DefaultRegistry(), marker))
if isDeclarativeValidationMarker(marker) {
marker := extractDeclarativeValidationMarker(marker, &ast.Comment{})
if marker == nil {
return false
}

return ms.HasWithArgumentsAndPayload(marker.Identifier, marker.Arguments, marker.Payload)
}

id, args, payload := extractMarkerIDArgumentsAndPayload(DefaultRegistry(), marker)

return ms.HasWithArgumentsAndPayload(id, args, payload)
}

// HasWithExpressions returns whether marker(s) with the identifier and
// expressions are present in the MarkerSet.
func (ms MarkerSet) HasWithExpressions(identifier string, expressions map[string]string) bool {
// HasWithArgumentsAndPayload returns whether marker(s) with the
// identifier, arguments, and payload are present in the MarkerSet.
func (ms MarkerSet) HasWithArgumentsAndPayload(identifier string, arguments map[string]string, payload Payload) bool {
markers, ok := ms[identifier]
if !ok {
return false
}

for _, marker := range markers {
if reflect.DeepEqual(marker.Expressions, expressions) {
if reflect.DeepEqual(marker.Arguments, arguments) && reflect.DeepEqual(marker.Payload, payload) {
return true
}
}
Expand Down
Loading