Skip to content

feat: add Conflictingmarkers #126

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 4 commits into
base: main
Choose a base branch
from

Conversation

yongruilin
Copy link
Contributor

@yongruilin yongruilin commented Jul 16, 2025

Ref: #97

Adds a new configurable linter that detects mutually exclusive markers. The linter allows users to define custom conflict sets and is not enabled by default.

@k8s-ci-robot k8s-ci-robot requested a review from JoelSpeed July 16, 2025 05:50
@k8s-ci-robot
Copy link
Contributor

[APPROVALNOTIFIER] This PR is NOT APPROVED

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

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

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 requested a review from jpbetz July 16, 2025 05:50
@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 Jul 16, 2025
@yongruilin yongruilin changed the title feat: add Conflictmarkers feat: add Conflictingmarkers Jul 16, 2025
Copy link
Contributor

@JoelSpeed JoelSpeed left a comment

Choose a reason for hiding this comment

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

Thanks for raising this, looking mostly there but found a few things I think we can improve, mainly around documentation and how we output the messages

docs/linters.md Outdated
Comment on lines 90 to 91
- Set A: `+default`, `+kubebuilder:default`
- Set B: `+required`, `+kubebuilder:validation:Required`
Copy link
Contributor

Choose a reason for hiding this comment

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

Is there a k8s:default marker confirmed yet? Or even, are we expecting to create one?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Not for now, but we do expect to create one. No harm to add it here.

docs/linters.md Outdated
The `conflictingmarkers` linter detects and reports when mutually exclusive markers are used on the same field.
This prevents common configuration errors and unexpected behavior in Kubernetes API types.

The linter includes built-in checks for the following conflicts:
Copy link
Contributor

Choose a reason for hiding this comment

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

Are there any other marker combinations that we want to include in this initial phase?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Not that I'm aware of.

docs/linters.md Outdated
### Configuration

The linter is configurable and allows users to define their own custom sets of conflicting markers.
Each custom conflict set must specify a name, two sets of markers, and a description of why they conflict.
Copy link
Contributor

Choose a reason for hiding this comment

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

I'm guessing the description will be used in the linter output when it detects these conflicts?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes. It is being used in the report.

func defaultConflictSets() []ConflictSet {
return []ConflictSet{
{
Name: "optional_vs_required",
Copy link
Contributor

Choose a reason for hiding this comment

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

Is this used at all? I can't see it being used, should it be in the message, perhaps before the value lists

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I think description should provide sufficient info? The name is used for linter author to track the sets easier.

Copy link
Contributor

Choose a reason for hiding this comment

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

I don't actually see the name being used anywhere apart from it being checked that it is unique?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

It is used when validating the config but not used when reporting.

config := &conflictingmarkers.ConflictingMarkersConfig{
CustomConflicts: []conflictingmarkers.ConflictSet{
{
Name: "custom_conflict",
Copy link
Contributor

Choose a reason for hiding this comment

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

I wonder if there is ever a case for a conflicting marker with attributes? For example XValidation in Kubebuilder has multiple properties, could you create conflicts with something like that? Are there any markers in the declarative validation project that have attributes like XValidation does?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Hmmm, declarative validation parses tags differently using "arguments" . I think keeping conflicting set focus on marker id is simpler.

type ConflictSet struct {
// Name is a human-readable name for this conflict set.
Name string `json:"name"`
// SetA contains the first set of conflicting markers.
Copy link
Contributor

Choose a reason for hiding this comment

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

Need to make this description obvious that it is A conflicts with B and not conflicts within setA

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Updated the comment.

@@ -0,0 +1,30 @@
package b

type CustomConflictStruct struct {
Copy link
Contributor

Choose a reason for hiding this comment

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

Include a case from the other tests to make sure that enabling custom markers doesn't disable the built in markers

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Added one more test case.

Comment on lines 65 to 92
Entry("With missing name", testCase{
config: conflictingmarkers.ConflictingMarkersConfig{
CustomConflicts: []conflictingmarkers.ConflictSet{
{
Name: "",
SetA: []string{"marker1"},
SetB: []string{"marker2"},
Description: "Test conflict",
},
},
},
expectedErr: "conflictingmarkers.customConflicts[0].name: Required value: name is required",
}),
Copy link
Contributor

Choose a reason for hiding this comment

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

Please add a test for missing description as well

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Is the one on line91 you looking for?

Copy link
Contributor

Choose a reason for hiding this comment

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

Yes, I can't read 😅

@JoelSpeed
Copy link
Contributor

Had a chat with @everettraven about this linter and a pattern we were looking at in #111. I think we should look to implement the same pattern here.

The pattern would be to create a generic base linter that has no configuration. Users could then configure this with their own config of conflicting markers should they so desire.

Then, for the rules we know we want, we create instances of the base marker with pre-defined configuration.

This means users would see defaultorrequired as an option as a linter, but under the hood, it's just this linter

I think in terms of implementation, the defaultorrequired linter would need to just init the current linter.

func Initializer() initializer.AnalyzerInitializer {
  return initializer.NewInitializer(
    "defaultorrequired",
    func(_ any) (*analysis.Analyzer, error) {
      return conflictingmarkers.Initializer().Init(&conflictingmarkers.ConflictingMarkersConfig{
        // Insert the correct config here
      })
    },
    true,
  )
}

This means that, should a user desire to have their own custom conflicting markers without the default set of conflicts we've identified, they can do so, and they can choose explicitly which of the conflicts they do and don't care about.

@yongruilin
Copy link
Contributor Author

yongruilin commented Jul 17, 2025

This means that, should a user desire to have their own custom conflicting markers without the default set of conflicts we've identified, they can do so, and they can choose explicitly which of the conflicts they do and don't care about.

It is a good idea. Then I think it is make sense to remove a built-in conflict sets to make it fully flexible?

@everettraven
Copy link
Contributor

Then I think it is make sense to remove a built-in conflict sets to make it fully flexible?

I might not have enough of the context to fully grok this question, so feel free to ignore if I've misinterpretted.

My understanding is that we do still want to have some opinionated built-in conflict identification, but these should be distinct linters that can be enabled/disabled without also having to enable/disable the custom conflict rules.

I would expect any conflicts that we identify from reading the Kube API conventions, declarative validation, and controller-gen markers we have a linter (or multiple) for so users don't have to connect those dots themselves in a set of custom conflict rules.

@yongruilin yongruilin force-pushed the conflictmarkers branch 2 times, most recently from fdabe40 to f1480a6 Compare July 17, 2025 21:34
@yongruilin
Copy link
Contributor Author

yongruilin commented Jul 17, 2025

I pushed a new commit to add an option to disable the built-in conflicts.

@JoelSpeed
Copy link
Contributor

@yongruilin I took your branch and pushed a couple of commits on a branch on my fork, this is kind of what I was imagining in my comment, WDYT of https://github.com/kubernetes-sigs/kube-api-linter/compare/main...JoelSpeed:kube-api-linter:pr/yongruilin/126?expand=1

@everettraven please also check this aligns with what you were expecting

@yongruilin
Copy link
Contributor Author

@JoelSpeed thanks for composing this.

Basically we have 2 ways to proceed on conflictingmarkers

  1. No built-in, conflictSets can only be configured and disabled by default. (Your commits)
  2. Built-in with customized conflict sets and enabled by default. (Current implementation)

For 1, is it worth the effort to introduce new linters? Keeping common conflict cases as built-in looks simpler to me.

@everettraven
Copy link
Contributor

@yongruilin I took your branch and pushed a couple of commits on a branch on my fork, this is kind of what I was imagining in my comment, WDYT of https://github.com/kubernetes-sigs/kube-api-linter/compare/main...JoelSpeed:kube-api-linter:pr/yongruilin/126?expand=1

@everettraven please also check this aligns with what you were expecting

This does align with what I was expecting.

Basically we have 2 ways to proceed on conflictingmarkers

  1. No built-in, conflictSets can only be configured and disabled by default. (Your commits)
  2. Built-in with customized conflict sets and enabled by default. (Current implementation)

For 1, is it worth the effort to introduce new linters? Keeping common conflict cases as built-in looks simpler to me.

I would agree that keeping common conflict cases would be simpler in implementation and maintenance burden on maintainers, but I think it causes UX limitations that are not ideal.

Adding new conflicting sets for option 2 would also likely be considered a breaking change as it could be more restrictive. We would have to solve for how we add new built-in conflict rules as opt-in. I don't think option 1 has this same problem because we can always add new non-default linters that users can opt-in to use - we don't have to reinvent the wheel to solve that issue.

For example, consider the following use cases:

  • I want to enable my own custom conflicting cases and do not want to use any of the defaults
    • Using option 1, they can enable the conflictingmarkers linter with their custom configuration and intuitively disable all of the opinionated default linters we have that builds on the conflictingmarkers implementation.
    • Using option 2, they cannot disable the built-ins without a new configuration option to do so (and IMO this isn't as intuitive as disabling separate linter(s)). Without the additional configuration option they end up having to disable all of their custom conflict rules as well.
  • I want to enable my own custom conflicting cases and only want to use a subset of the defaults
    • Option 1 facilitates this well
    • Option 2, the configuration option has now gotten even more complex and more unintuitive.

IMO we should prefer UX > maintainer burden where possible.

@JoelSpeed
Copy link
Contributor

I can't really summarise my thoughts any better than @everettraven has, but yes, my priority here is not for maintainer burden (though I am trying hard to make that easy), but for the end user UX and making it easy to configure.

I think the points about how would we possibly add new conflicts to the built in markers without being breaking is also an important point that weighs into the decision here.

I'm very keen to go for option 1 at the moment and see if that decision ages well over time

@yongruilin
Copy link
Contributor Author

Ok, I agree with this approach. I've already updated to be fully configurable with no built-in conflict sets, which fits with the generic base linter concept.
I'd like to split this into two PRs though - the current one for the underlying generic linter, and a follow-up for the opinionated wrapper linters (like defaultorrequired) that use the base linter with pre-defined configs. This should make the review process cleaner.

docs/linters.md Outdated
Comment on lines 109 to 115
setA:
- "default"
- "+kubebuilder:default"
setB:
- "required"
- "+kubebuilder:validation:Required"
- "+k8s:validation:required"
Copy link
Member

Choose a reason for hiding this comment

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

I think setA and setB is ambiguous. For example, how about first / second ? If this is it, when we extend set, it's easy to add like third.

Copy link
Contributor

Choose a reason for hiding this comment

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

I was also thinking about the naming and couldn't come up with something I was absolutely happy with, this is a hard case to name 🤔

I could see first and second working, but does that imply some sort of ordering? Where I don't think there actually is any ordering?

Copy link
Contributor

@everettraven everettraven Jul 23, 2025

Choose a reason for hiding this comment

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

What if we just made it:

conflicts:
- name: "conflict_one"
   sets:
   - ["default", "kubebuilder:default"]
   - ["required", "kubebuilder:Required"]
   - ...

We could, for now, limit sets to just two entries? Also, do we ever envision needing to extend this beyond 2 sets for a single conflict rule? How would/should that be handled?

Another naming option could be left and right? No ordering is signaled there and is something that I've seen used to represent comparison operations in the past.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Good idea, we actually allow multiple sets in validation-gen's lint. We should handle it that all sets are conflicting with each other.

Copy link
Contributor

Choose a reason for hiding this comment

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

This would open up the case for something that correctly allows you to check that you only have on of [MinItems,MaxItems], [MinProperties,MaxProperties], [MinLength,MaxLength] since we know that these are for different resources types (array, map/object, string)

Copy link
Member

Choose a reason for hiding this comment

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

@everettraven
Good idea, totally agree it.

Comment on lines +40 to +44
if len(in.expectedErr) > 0 {
Expect(errs.ToAggregate()).To(MatchError(in.expectedErr))
} else {
Expect(errs).To(HaveLen(0), "No errors were expected")
}
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
if len(in.expectedErr) > 0 {
Expect(errs.ToAggregate()).To(MatchError(in.expectedErr))
} else {
Expect(errs).To(HaveLen(0), "No errors were expected")
}
if len(in.expectedErr) > 0 {
Expect(errs.ToAggregate()).To(MatchError(in.expectedErr))
return
}
Expect(errs).To(HaveLen(0), "No errors were expected")

It's easy to read.

Copy link
Member

Choose a reason for hiding this comment

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

Please add test case that has +marker1 and +marker2. It must be valid.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Added.

docs/linters.md Outdated
Comment on lines 109 to 115
setA:
- "default"
- "+kubebuilder:default"
setB:
- "required"
- "+kubebuilder:validation:Required"
- "+k8s:validation:required"
Copy link
Contributor

Choose a reason for hiding this comment

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

I was also thinking about the naming and couldn't come up with something I was absolutely happy with, this is a hard case to name 🤔

I could see first and second working, but does that imply some sort of ordering? Where I don't think there actually is any ordering?

}

// validateConfig validates the configuration in the config.ConflictingMarkersConfig struct.
func validateConfig(cfg *ConflictingMarkersConfig, fldPath *field.Path) field.ErrorList {
Copy link
Contributor

Choose a reason for hiding this comment

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

We should probably validate that there is at least one item in the conflict set shouldn't we? Adding this with no config doesn't make sense

Comment on lines 58 to 59
Name: name,
Doc: "Check that fields do not have conflicting markers from mutually exclusive sets",
Copy link
Contributor

Choose a reason for hiding this comment

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

Nit: make this optionally configurable so we can easily change this in future wrappers of this analyzer

conflictSets []ConflictSet
}

func newAnalyzer(cfg *ConflictingMarkersConfig) *analysis.Analyzer {
Copy link
Contributor

Choose a reason for hiding this comment

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

Export this so other distinct linters can wrap this in the future?

Copy link
Contributor

Choose a reason for hiding this comment

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

In the example I provided, I wrapped the Initializer function rather than this function, I don't think this needs to be exported does it?

Copy link
Contributor

Choose a reason for hiding this comment

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

Ah, I see.

What does the output of that linter look like if the initializer uses one name but the analyzer that is returned uses a different name?

In the example you provided, I suspect that the initializer properly returns that it is for the defaultorrequired linter, but the analysis.Analyzer returned would have the name conflictingmarkers. If this is true and both names (or just the analysis.Analyzer name) manifest in the output, it might be confusing to users that have disabled the conflictingmarkers linter but see that name pop up if they have the defaultorrequired linter enabled.

Copy link
Contributor

Choose a reason for hiding this comment

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

AFAICT the name is only used on the outer layer, we see the name of the Initializer in the linter line before the message, but, because we run the initialization and then wrap the initialized Analyzer, we override the name of the package output Initializer on the new implementation, so, I believe this is safe


for i, conflictSet := range cfg.Conflicts {
if nameSet.Has(conflictSet.Name) {
fieldErrors = append(fieldErrors, field.Invalid(fldPath.Child("conflicts").Index(i).Child("name"), conflictSet.Name, "repeated value, names must be unique"))
Copy link
Contributor

Choose a reason for hiding this comment

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

The field.Duplicate() function may be more appropriate here.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Good catch, updated.

@@ -17,6 +18,7 @@
- [StatusOptional](#statusoptional) - Ensures status fields are marked as optional
- [StatusSubresource](#statussubresource) - Validates status subresource configuration
- [UniqueMarkers](#uniquemarkers) - Ensures unique marker definitions
- [ConflictingMarkers](#conflictingmarkers) - Detects and reports when mutually exclusive markers are used on the same field
Copy link
Contributor

Choose a reason for hiding this comment

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

This entry is now in the list twice, and it appears we have the documentation twice too?

Comment on lines +56 to +60
// Use configured documentation or fall back to default
doc := cfg.Doc
if doc == "" {
doc = "Check that fields do not have conflicting markers from mutually exclusive sets"
}
Copy link
Contributor

Choose a reason for hiding this comment

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

Oh interesting, hadn't thought about this, when we wrap the analyzer this makes sense doesn't it

@JoelSpeed
Copy link
Contributor

LGTM one we've fixed that docs issue of seeing double, thanks for working on this one @yongruilin

Comment on lines +20 to +22
// Doc is the documentation string for the analyzer.
// If not provided, a default description will be used.
Doc string `json:"doc"`
Copy link
Contributor

Choose a reason for hiding this comment

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

Is this leaking implementation details into user-facing configuration options?

For example, as an end user I would now be able to configure this in a way that the conflicting markers analyzer uses a custom doc string.

I would generally think this is something that we don't want to allow users to change in configuration.

Copy link
Contributor

Choose a reason for hiding this comment

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

Where does this doc string actually show up? Does it matter if analyzers wrapping this one don't have the option to change it?

Copy link
Contributor

Choose a reason for hiding this comment

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

I believe it would show under the output of golangci-lint help linters. I do not know if golangci-lint picks up the analyzers from the custom linter config though

Copy link
Contributor

Choose a reason for hiding this comment

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

Can we add a test for the multi-way conflict that ensures we identify a conflict when only a subset of the conflicts are triggered?

In this case I would expect to see a test where there is conflicting markers from 2 sets in the 3-way conflict set still trigger the 3-way conflict.

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