Skip to content
Open
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
6 changes: 6 additions & 0 deletions charts/reports-server/templates/cluster-roles.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,12 @@ rules:
- update
- watch
- deletecollection
- apiGroups:
- apiextensions.k8s.io
resources:
- customresourcedefinitions
verbs:
- list
- apiGroups:
- ''
- events.k8s.io
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ require (
k8s.io/component-base v0.29.2
k8s.io/klog/v2 v2.130.1
k8s.io/kube-aggregator v0.29.2
k8s.io/apiextensions-apiserver v0.29.2
k8s.io/kube-openapi v0.0.0-20240228011516-70dd3763d340
openreports.io v0.0.0-00010101000000-000000000000
sigs.k8s.io/wg-policy-prototypes v0.0.0-20231226153523-db3ef51d230f
Expand Down Expand Up @@ -286,7 +287,6 @@ require (
gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
k8s.io/apiextensions-apiserver v0.29.2 // indirect
k8s.io/cli-runtime v0.29.2 // indirect
k8s.io/kms v0.29.2 // indirect
k8s.io/kubectl v0.29.2 // indirect
Expand Down
11 changes: 10 additions & 1 deletion pkg/app/policyserver.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"time"

"github.com/kyverno/reports-server/pkg/app/opts"
"github.com/kyverno/reports-server/pkg/crdcoexistence"
"github.com/kyverno/reports-server/pkg/server"
"github.com/spf13/cobra"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
Expand Down Expand Up @@ -87,8 +88,16 @@ func runCommand(o *opts.Options, stopCh <-chan struct{}) error {
if err != nil {
return err
}

ctx, cancel := context.WithCancel(context.Background())
go func() {
<-stopCh

@aerosouund aerosouund May 12, 2026

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.

There are a few things i wanna comment on in this bit:
First, if the PeriodicallyCheckForNewConflicts detect a conflict, it will cancel the context. and RunUntil will exit because the done channel became readable. This means that cleanups we are running on events received through the stopCh won't run.

Second, I think its added complexity to create a context that you cancel when you detect a conflict, run a goroutine that checks if stopCh became readable (something else triggered a server shutdown signal) and cancel the periodic check goroutine.
Why not just pass the stopCh as an argument to the function, run a for select on it becoming readable (something else cancelled) and when a conflict is detected you close this channel yourself ?

Lastly, i am a bit against making this whole thing periodic in the first place. I prefer if its only once on startup. If the admin installed a conflicting CRD, it should be on them to restart the server (to make sure they are doing it in line with keeping their services available).
We can help them out by logging a message that nudges them to do so. Something like there's a CRD installed that conflicts with the CRDs the reports server serves, please ensure to restart the server once after CRD installation to avoid serving duplicate prefixes. Or something less verbose?

cancel()
}()
go crdcoexistence.PeriodicallyCheckForNewConflicts(ctx, cancel, config.Rest, config.OpenAPIIgnorePrefixes)

go func() {
if err := s.RunUntil(stopCh); err != nil {
if err := s.RunUntil(ctx.Done()); err != nil {
klog.ErrorS(err, "failed to run server")
os.Exit(1)
}
Expand Down
64 changes: 64 additions & 0 deletions pkg/crdcoexistence/detect.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package crdcoexistence

import (
"context"
"time"

apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
apiextensionsclient "k8s.io/apiextensions-apiserver/pkg/client/clientset/clientset"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/rest"
"k8s.io/klog/v2"
)

var conflictGroups = map[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.

a map of string to empty struct is better here. an empty struct is 0 bytes.

"wgpolicyk8s.io": true,
"openreports.io": 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.

why do we only have these and not the other crds the reports server serves ?

}

// DetectConflictingCRDs checks the cluster for CRDs in API groups that
// reports-server also serves via APIService. When both a CRD and an APIService
// exist for the same group/version, the kube-apiserver's OpenAPI aggregator
// produces duplicate-path errors (K8s 1.28+). For each conflicting group found,
// the corresponding OpenAPI path prefix is returned so it can be added to
// IgnorePrefixes — suppressing reports-server's OpenAPI paths for that group
// while keeping the APIService request routing fully functional.
func DetectConflictingCRDs(restConfig *rest.Config) []string {

@aerosouund aerosouund May 12, 2026

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.

its better if this function returns an error and the error is ignored at the caller level. Also it doesn't have to be public

if restConfig == nil {
return nil
}

ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)

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 not pass the context given to PeriodicallyCheckForNewConflicts ?

defer cancel()

client, err := apiextensionsclient.NewForConfig(restConfig)
if err != nil {
klog.V(2).InfoS("unable to create apiextensions client for CRD conflict detection, skipping", "error", err)
return nil
}

crdList, err := client.ApiextensionsV1().CustomResourceDefinitions().List(ctx, metav1.ListOptions{})
if err != nil {
klog.V(2).InfoS("unable to list CRDs for conflict detection, skipping", "error", err)
return nil
}

return detectFromCRDList(crdList.Items)
}

func detectFromCRDList(crds []apiextensionsv1.CustomResourceDefinition) []string {

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 think its best to drop this helper entirely and move its logic to DetectConflictingCrds

seen := make(map[string]bool)
var prefixes []string

for _, crd := range crds {
group := crd.Spec.Group
if !conflictGroups[group] || seen[group] {

@aerosouund aerosouund May 12, 2026

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.

can you make this a variable that gets supplied to the function rather than a global ? it's fine if the caller references the global. but its ok for the caller to pass the global variable. it makes it easier to guess about the function's intent and what it does.

continue
}
seen[group] = true
klog.InfoS("detected third-party CRD for API group served by reports-server, suppressing OpenAPI paths to avoid duplicate-path errors",
"group", group)
prefixes = append(prefixes, "/apis/"+group+"/")
}
return prefixes
}
82 changes: 82 additions & 0 deletions pkg/crdcoexistence/detect_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
package crdcoexistence

import (
"sort"
"testing"

apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)

func TestDetectConflictingCRDs_NilConfig(t *testing.T) {
if result := DetectConflictingCRDs(nil); result != nil {
t.Errorf("expected nil, got %v", result)
}
}

func TestDetectFromCRDList(t *testing.T) {
makeCRD := func(name, group string) apiextensionsv1.CustomResourceDefinition {
return apiextensionsv1.CustomResourceDefinition{
ObjectMeta: metav1.ObjectMeta{Name: name},
Spec: apiextensionsv1.CustomResourceDefinitionSpec{Group: group},
}
}

tests := []struct {
name string
crds []apiextensionsv1.CustomResourceDefinition
expected []string
}{
{
name: "no CRDs",
},
{
name: "unrelated CRD",
crds: []apiextensionsv1.CustomResourceDefinition{makeCRD("foos.example.com", "example.com")},
},
{
name: "wgpolicyk8s CRD",
crds: []apiextensionsv1.CustomResourceDefinition{makeCRD("policyreports.wgpolicyk8s.io", "wgpolicyk8s.io")},
expected: []string{"/apis/wgpolicyk8s.io/"},
},
{
name: "openreports CRD",
crds: []apiextensionsv1.CustomResourceDefinition{makeCRD("reports.openreports.io", "openreports.io")},
expected: []string{"/apis/openreports.io/"},
},
{
name: "both groups",
crds: []apiextensionsv1.CustomResourceDefinition{
makeCRD("policyreports.wgpolicyk8s.io", "wgpolicyk8s.io"),
makeCRD("reports.openreports.io", "openreports.io"),
},
expected: []string{"/apis/openreports.io/", "/apis/wgpolicyk8s.io/"},
},
{
name: "duplicate CRDs in same group",
crds: []apiextensionsv1.CustomResourceDefinition{
makeCRD("policyreports.wgpolicyk8s.io", "wgpolicyk8s.io"),
makeCRD("clusterpolicyreports.wgpolicyk8s.io", "wgpolicyk8s.io"),
},
expected: []string{"/apis/wgpolicyk8s.io/"},
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := detectFromCRDList(tt.crds)
sort.Strings(result)
sort.Strings(tt.expected)

if len(result) != len(tt.expected) {
t.Errorf("expected %v, got %v", tt.expected, result)
return
}
for i := range result {
if result[i] != tt.expected[i] {
t.Errorf("expected %v, got %v", tt.expected, result)
}
}
})
}
}
38 changes: 38 additions & 0 deletions pkg/crdcoexistence/periodic.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package crdcoexistence

import (
"context"
"time"

"k8s.io/client-go/rest"
"k8s.io/klog/v2"
)

// PeriodicallyCheckForNewConflicts re-runs CRD conflict detection on a timer.
// If a new conflicting CRD appears that was not present at startup, the cancel
// function is called to trigger a graceful restart.
func PeriodicallyCheckForNewConflicts(ctx context.Context, cancel context.CancelFunc, restConfig *rest.Config, initialPrefixes []string) {
known := make(map[string]bool, len(initialPrefixes))

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.

map[string]struct{}

for _, p := range initialPrefixes {
known[p] = true
}

ticker := time.NewTicker(2 * time.Minute)
defer ticker.Stop()

for {
select {
case <-ctx.Done():
return
case <-ticker.C:
current := DetectConflictingCRDs(restConfig)
for _, p := range current {
if !known[p] {

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.

here, you are checking if we detected new conflicting CRDs other than the ones in config.OpenAPIIgnorePrefixes but this field won't persist across startups anyways, and the user doesn't have a way to supply their own ignore prefixes. so why not just skip passing and comparing against the initial prefixes array and report if we detect any of the api groups that can cause problems in a CRD ?

klog.InfoS("new conflicting CRD detected since startup, restarting to apply OpenAPI suppression", "prefix", p)
cancel()
return
}
}
}
}
}
9 changes: 9 additions & 0 deletions pkg/server/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"github.com/hashicorp/go-multierror"
"github.com/kyverno/reports-server/pkg/api"
"github.com/kyverno/reports-server/pkg/app/opts"
"github.com/kyverno/reports-server/pkg/crdcoexistence"
"github.com/kyverno/reports-server/pkg/storage"
storageapi "github.com/kyverno/reports-server/pkg/storage/api"
"github.com/kyverno/reports-server/pkg/storage/db"
Expand Down Expand Up @@ -41,6 +42,7 @@ type Config struct {
SkipMigration bool
APIServiceReconcileInterval time.Duration
KubeClient *kubernetes.Clientset
OpenAPIIgnorePrefixes []string
}

func NewServerConfig(o opts.Options) (*Config, error) {
Expand Down Expand Up @@ -88,6 +90,12 @@ func NewServerConfig(o opts.Options) (*Config, error) {
return nil, err
}

ignorePrefixes := crdcoexistence.DetectConflictingCRDs(restConfig)
if len(ignorePrefixes) > 0 {
apiserver.OpenAPIConfig.IgnorePrefixes = append(apiserver.OpenAPIConfig.IgnorePrefixes, ignorePrefixes...)
apiserver.OpenAPIV3Config.IgnorePrefixes = append(apiserver.OpenAPIV3Config.IgnorePrefixes, ignorePrefixes...)
}

config := &Config{
Apiserver: apiserver,
Rest: restConfig,
Expand All @@ -100,6 +108,7 @@ func NewServerConfig(o opts.Options) (*Config, error) {
Store: store,
SkipMigration: o.SkipMigration,
APIServiceReconcileInterval: o.APIServiceReconcileInterval,
OpenAPIIgnorePrefixes: ignorePrefixes,
}

return config, nil
Expand Down