Skip to content

Add discriminated unions structure linter - #230

Open
MatteoFari wants to merge 1 commit into
kubernetes-sigs:mainfrom
MatteoFari:discriminatedunions-linter
Open

Add discriminated unions structure linter#230
MatteoFari wants to merge 1 commit into
kubernetes-sigs:mainfrom
MatteoFari:discriminatedunions-linter

Conversation

@MatteoFari

Copy link
Copy Markdown
Contributor

Description

This PR introduces a new discriminatedunions linter to enforce structural best practices for discriminated unions.

The new linter, discriminatedunions, does the following:

  • Validates Union Structure: Detects unions from either legacy markers or declarative markers, including field-marker-only unions.
  • Enforces Discriminator/Member Rules: Requires exactly one discriminator field, requires the discriminator to be marked required, and requires union member fields to be optional (including support for +unionMember,optional).
  • Configurable Non-Member Policy: Enforces or allows non-member fields via config (nonMemberFields: Forbid | Allow), with Forbid as default.
  • Safe Rollout: The linter is disabled by default.

This is the first part and intentionally scopes to structure validation only.
CRD reachability and CEL gating checks will follow.

Part of #20

@k8s-ci-robot k8s-ci-robot added the do-not-merge/work-in-progress Indicates that a PR should not merge because it is a work in progress. label Feb 27, 2026
@k8s-ci-robot

Copy link
Copy Markdown
Contributor

[APPROVALNOTIFIER] This PR is NOT APPROVED

This pull-request has been approved by: MatteoFari
Once this PR has been reviewed and has the lgtm label, please assign everettraven for approval. For more information see the Code Review Process.

The full list of commands accepted by this bot can be found here.

Details Needs approval from an approver in each of these files:

Approvers can indicate their approval by writing /approve in a comment
Approvers can cancel approval by writing /approve cancel in a comment

@k8s-ci-robot k8s-ci-robot added cncf-cla: yes Indicates the PR's author has signed the CNCF CLA. size/XL Denotes a PR that changes 500-999 lines, ignoring generated files. labels Feb 27, 2026
@MatteoFari
MatteoFari marked this pull request as ready for review February 27, 2026 08:19
@k8s-ci-robot k8s-ci-robot removed the do-not-merge/work-in-progress Indicates that a PR should not merge because it is a work in progress. label Feb 27, 2026
@k8s-ci-robot
k8s-ci-robot requested a review from jpbetz February 27, 2026 08:19

"golang.org/x/tools/go/analysis"
"golang.org/x/tools/go/analysis/passes/inspect"
"golang.org/x/tools/go/ast/inspector"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

We have own inspector, so please use it instead of this.

Comment on lines +40 to +45
legacyUnionMarker = "union"
legacyUnionDiscriminatorMarker = "unionDiscriminator"
legacyUnionDiscriminatorLowerMarker = "uniondiscriminator"
legacyUnionMemberMarker = "unionMember"
k8sUnionDiscriminatorMarker = "k8s:unionDiscriminator"
k8sUnionMemberMarker = "k8s:unionMember"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Name: name,
Doc: "Validates discriminated-union marker structure.",
Run: a.run,
Requires: []*analysis.Analyzer{inspect.Analyzer, markershelper.Analyzer, extractjsontags.Analyzer},

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
Requires: []*analysis.Analyzer{inspect.Analyzer, markershelper.Analyzer, extractjsontags.Analyzer},
[]*analysis.Analyzer{inspector.Analyzer}

Comment on lines +124 to +156
inspectPass, ok := pass.ResultOf[inspect.Analyzer].(*inspector.Inspector)
if !ok {
return nil, kalerrors.ErrCouldNotGetInspector
}

markersAccess, ok := pass.ResultOf[markershelper.Analyzer].(markershelper.Markers)
if !ok {
return nil, kalerrors.ErrCouldNotGetMarkers
}

jsonTags, ok := pass.ResultOf[extractjsontags.Analyzer].(extractjsontags.StructFieldTags)
if !ok {
return nil, kalerrors.ErrCouldNotGetJSONTags
}

inspectPass.Preorder([]ast.Node{(*ast.TypeSpec)(nil)}, func(n ast.Node) {
typeSpec, ok := n.(*ast.TypeSpec)
if !ok || typeSpec.Name == nil {
return
}

structType, ok := typeSpec.Type.(*ast.StructType)
if !ok {
return
}

union := buildUnionType(typeSpec, structType, markersAccess, jsonTags)
if union == nil {
return
}

a.reportStructureViolations(pass, union)
})

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

You can use InspecTypeSpec.

}

func markerSpecifiesOptionalMember(marker markershelper.Marker) bool {
if strings.Contains(strings.ToLower(marker.String()), ",optional") {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I don't prefer string matching pattern. Is marker.Arguments insufficient ?

return false
}

func isTruthyMarkerValue(value string) bool {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Why do we need it ? I could not come up with pattern.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

to normalize a couple of possible encodings in one place, but that was broader than this linter really needed.

Comment on lines +40 to +43
legacyUnionMarker = "union"
legacyUnionDiscriminatorMarker = "unionDiscriminator"
legacyUnionDiscriminatorLowerMarker = "uniondiscriminator"
legacyUnionMemberMarker = "unionMember"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
legacyUnionMarker = "union"
legacyUnionDiscriminatorMarker = "unionDiscriminator"
legacyUnionDiscriminatorLowerMarker = "uniondiscriminator"
legacyUnionMemberMarker = "unionMember"
UnionMarker = "union"
UnionDiscriminatorMarker = "unionDiscriminator"
UnionDiscriminatorLowerMarker = "uniondiscriminator"
UnionMemberMarker = "unionMember"


func qualifyFieldName(typeName string, field *ast.Field) string {
fieldName := utils.FieldName(field)
if fieldName == "" {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Why do you fallback fieldName ?

@MatteoFari
MatteoFari force-pushed the discriminatedunions-linter branch from db91ce1 to e29cad4 Compare March 16, 2026 16:51
@MatteoFari

Copy link
Copy Markdown
Contributor Author

@sivchari if the changes are good i can continue with CRD reachability and CEL enforcement.

Comment thread .DS_Store Outdated

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Please remove it.

}

func normalizedMarkerValue(value string) string {
return strings.ToLower(strings.TrimSpace(strings.Trim(value, `"'`)))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I'm not sure why we make value lower.

Comment thread pkg/markers/markers.go Outdated
UnionDiscriminatorMarker = "unionDiscriminator"

// UnionDiscriminatorLowerMarker is the lowercase variant of UnionDiscriminatorMarker accepted by existing APIs.
UnionDiscriminatorLowerMarker = "uniondiscriminator"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Is it right ? At least, I've only found useDiscriminator in k/k
https://github.com/search?q=repo%3Akubernetes%2Fkubernetes+uniondiscriminator&type=code

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

yeah it was just too defensive

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Please add license to header.

Comment on lines +309 to +311
if ok {
return normalizedMarkerValue(value) == "true"
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

optional does not have value.

@MatteoFari
MatteoFari force-pushed the discriminatedunions-linter branch 2 times, most recently from cb6425b to 594b077 Compare March 17, 2026 09:37
@k8s-ci-robot k8s-ci-robot added the needs-rebase Indicates a PR cannot be merged because it has merge conflicts with HEAD. label Mar 20, 2026
@MatteoFari
MatteoFari force-pushed the discriminatedunions-linter branch from 594b077 to f66067a Compare March 20, 2026 09:20
@k8s-ci-robot k8s-ci-robot removed the needs-rebase Indicates a PR cannot be merged because it has merge conflicts with HEAD. label Mar 20, 2026
@k8s-triage-robot

Copy link
Copy Markdown

The Kubernetes project currently lacks enough contributors to adequately respond to all PRs.

This bot triages PRs according to the following rules:

  • After 90d of inactivity, lifecycle/stale is applied
  • After 30d of inactivity since lifecycle/stale was applied, lifecycle/rotten is applied
  • After 30d of inactivity since lifecycle/rotten was applied, the PR is closed

You can:

  • Mark this PR as fresh with /remove-lifecycle stale
  • Close this PR with /close
  • Offer to help out with Issue Triage

Please send feedback to sig-contributor-experience at kubernetes/community.

/lifecycle stale

@k8s-ci-robot k8s-ci-robot added the lifecycle/stale Denotes an issue or PR has remained open with no activity and has become stale. label Jun 18, 2026
@JoelSpeed

Copy link
Copy Markdown
Contributor

/remove-lifecycle stale

@kubernetes-prow kubernetes-prow Bot removed the lifecycle/stale Denotes an issue or PR has remained open with no activity and has become stale. label Jul 3, 2026

@JoelSpeed JoelSpeed left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

How would we feel about optionally also enforcing CEL validations for the union membership?

There are two patterns we use in openshift, one for required members, one for optional members. They are tied only to the discriminator value and then the field name itself, so scale well.

The linter could automatically detect these/configure these for users possibly?

Comment thread pkg/analysis/discriminatedunions/config.go
Comment thread pkg/analysis/discriminatedunions/analyzer.go Outdated
Comment thread pkg/analysis/discriminatedunions/analyzer.go Outdated
Comment thread pkg/analysis/discriminatedunions/analyzer.go Outdated
Comment thread pkg/analysis/discriminatedunions/analyzer.go Outdated
Comment thread pkg/analysis/discriminatedunions/analyzer.go Outdated
Comment thread pkg/analysis/discriminatedunions/analyzer.go Outdated
Comment thread pkg/analysis/discriminatedunions/analyzer.go Outdated
Signed-off-by: Matteo Fari <matteofari06@gmail.com>
@MatteoFari
MatteoFari force-pushed the discriminatedunions-linter branch from f66067a to c17d507 Compare July 10, 2026 07:56
@kubernetes-prow

Copy link
Copy Markdown
Contributor

[APPROVALNOTIFIER] This PR is NOT APPROVED

This pull-request has been approved by: MatteoFari
Once this PR has been reviewed and has the lgtm label, please assign everettraven for approval. For more information see the Code Review Process.

The full list of commands accepted by this bot can be found here.

Details Needs approval from an approver in each of these files:

Approvers can indicate their approval by writing /approve in a comment
Approvers can cancel approval by writing /approve cancel in a comment

@MatteoFari

Copy link
Copy Markdown
Contributor Author

How would we feel about optionally also enforcing CEL validations for the union membership?

There are two patterns we use in openshift, one for required members, one for optional members. They are tied only to the discriminator value and then the field name itself, so scale well.

The linter could automatically detect these/configure these for users possibly?

I originally thought about this PR as a first part and later add CRD reachability and CEL gating checks. Do you prefer that I include them in this PR?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

cncf-cla: yes Indicates the PR's author has signed the CNCF CLA. size/XL Denotes a PR that changes 500-999 lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants