Skip to content
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

add performance tests for namespace-lister #38

Merged
merged 7 commits into from
Jan 28, 2025
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
31 changes: 29 additions & 2 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,35 @@
with:
go-version: ${{ steps.go_version.outputs.version }}

- name: Determine golang-ci version
run: make test
- name: Run tests
run: |
GINKGO="go run github.com/onsi/ginkgo/v2/ginkgo --github-output" make test

performance-tests:
name: Run performance tests
runs-on: ubuntu-24.04

steps:
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: "1.22"

- uses: kubernetes-sigs/kwok@main
with:
command: 'kwokctl'

- name: Set up kwokctl
uses: kubernetes-sigs/kwok@main
with:
command: kwokctl

- name: Checkout Git Repository
uses: actions/checkout@v4

- name: Run performance tests
run: |
GINKGO="go run github.com/onsi/ginkgo/v2/ginkgo --github-output" make test-perf

acceptance:
name: Run acceptance tests
Expand Down Expand Up @@ -119,7 +146,7 @@
id: golangci_version
run: |
echo "version=$(go mod edit -json hack/tools/golang-ci/go.mod | \
jq -r '.Require | map(select(.Path == "github.com/golangci/golangci-lint"))[].Version')" \

Check warning on line 149 in .github/workflows/ci.yaml

View workflow job for this annotation

GitHub Actions / Lint yaml manifests

149:101 [line-length] line too long (102 > 100 characters)
>> $GITHUB_OUTPUT

- name: Set up Go
Expand Down
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ ARG TARGETARCH
WORKDIR /namespace-lister

# Copy the Go Modules manifests
COPY go.mod go.sum .
COPY go.mod go.sum ./
Copy link
Collaborator

Choose a reason for hiding this comment

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

I don't see a difference in behavior - why is this needed?

Copy link
Member Author

Choose a reason for hiding this comment

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

I applied a fix for a complaint from my linter. When multiple files are copied, it's best practice to explicitly declare a folder.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Then maybe we should have something like this?

Suggested change
COPY go.mod go.sum ./
COPY go.mod go.sum /namespace-lister/

Copy link
Member Author

Choose a reason for hiding this comment

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

we are already setting the WORKDIR to /namespace-lister. I'd prefer this to remain relative to the WORKDIR

# cache deps before building and copying source so that we don't need to re-download as much
# and so that source changes don't invalidate our downloaded layer
RUN go mod download
Expand Down
16 changes: 15 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,14 @@ LOCALBIN := $(ROOT_DIR)/bin

OUT_DIR := $(ROOT_DIR)/out

GINKGO ?= ginkgo
GO ?= go

PERF_OUT_DIR := $(OUT_DIR)/perf
PERF_CLUSTER_PROVIDER ?= kwokctl
PERF_CLUSTER_KUBECONFIG ?= $(PERF_OUT_DIR)/namespace-lister-perf-test
PERF_CLUSTER_PROVIDER_FLAGS ?= --disable-qps-limits

GOLANG_CI ?= $(GO) run -modfile $(ROOT_DIR)/hack/tools/golang-ci/go.mod github.com/golangci/golangci-lint/cmd/golangci-lint

IMG ?= namespace-lister:latest
Expand Down Expand Up @@ -46,7 +52,15 @@ fmt: ## Run go fmt against code.

.PHONY: test
test: ## Run go test against code.
$(GO) test ./...
$(GINKGO) --label-filter='!perf'

.PHONY: test-perf
test-perf: ## Run performance tests
-$(PERF_CLUSTER_PROVIDER) delete cluster --name namespace-lister-perf-test
KUBECONFIG=$(PERF_CLUSTER_KUBECONFIG) $(PERF_CLUSTER_PROVIDER) create cluster \
--name namespace-lister-perf-test $(PERF_CLUSTER_PROVIDER_FLAGS)
KUBECONFIG=$(PERF_CLUSTER_KUBECONFIG) $(GINKGO) --label-filter='perf' \
--keep-going --procs=1 --flake-attempts 2 --output-dir=$(PERF_OUT_DIR)

.PHONY: image-build
image-build:
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
module github.com/konflux-ci/namespace-lister

go 1.22.2
go 1.22.9

require (
github.com/go-logr/logr v1.4.2
Expand Down
287 changes: 287 additions & 0 deletions performance_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,287 @@
package main

import (
"context"
"fmt"
"slices"
"time"

. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/onsi/gomega/gmeasure"
corev1 "k8s.io/api/core/v1"
rbacv1 "k8s.io/api/rbac/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/labels"
"k8s.io/apimachinery/pkg/runtime"
utilruntime "k8s.io/apimachinery/pkg/util/runtime"
"k8s.io/apiserver/pkg/authentication/user"
"k8s.io/apiserver/pkg/authorization/authorizer"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
)

const (
NamespaceTypeLabelKey string = "konflux-ci.dev/type"
NamespaceTypeUserLabelValue string = "user"
)

var _ = Describe("Authorizing requests", func() {
BeforeEach(func() {})

It("efficiently authorize on a huge environment", Serial, Label("perf"), func(ctx context.Context) {
// new gomega experiment
experiment := gmeasure.NewExperiment("Authorizing Request")

// Register the experiment as a ReportEntry - this will cause Ginkgo's reporter infrastructure
// to print out the experiment's report and to include the experiment in any generated reports
AddReportEntry(experiment.Name, experiment)

// prepare scheme
s := runtime.NewScheme()
utilruntime.Must(corev1.AddToScheme(s))
utilruntime.Must(rbacv1.AddToScheme(s))

// get kubernetes client config
restConfig := ctrl.GetConfigOrDie()
restConfig.QPS = 500
restConfig.Burst = 500
c, err := client.New(restConfig, client.Options{Scheme: s})
utilruntime.Must(err)

// create resources
username := "user"
err, ans, uns := createResources(ctx, c, username, 300, 800, 1200)
utilruntime.Must(err)

// create cache
ls, err := labels.Parse(fmt.Sprintf("%s=%s", NamespaceTypeLabelKey, NamespaceTypeUserLabelValue))
utilruntime.Must(err)
cacheConfig := cacheConfig{restConfig: restConfig, namespacesLabelSector: ls}
cache, err := BuildAndStartCache(ctx, &cacheConfig)
utilruntime.Must(err)

// create authorizer and namespacelister
authzr := NewAuthorizer(ctx, cache)
nl := NewNamespaceLister(cache, authzr)

// we sample a function repeatedly to get a statistically significant set of measurements
experiment.Sample(func(idx int) {
experiment.MeasureDuration("listing", func() {
nn, err := nl.ListNamespaces(ctx, username)
if err != nil {
panic(err)
}
if lnn := len(nn.Items); lnn != len(ans) {
panic(fmt.Errorf("expecting %d namespaces, received %d", len(ans), lnn))
}
})
}, gmeasure.SamplingConfig{N: 30, Duration: 2 * time.Minute})
// we'll sample the function up to 30 times or up to 2 minutes, whichever comes first.

// we sample a function repeatedly to get a statistically significant set of measurements
experiment.Sample(func(idx int) {
nsName := ans[0].GetName()
// measure how long it takes to allow a request and store the duration in a "authorization-allow" measurement
experiment.MeasureDuration("authorization-allow", func() {
d, _, err := authzr.Authorize(ctx, authorizer.AttributesRecord{
User: &user.DefaultInfo{Name: username},
Verb: "get",
Resource: "namespaces",
APIGroup: corev1.GroupName,
APIVersion: corev1.SchemeGroupVersion.Version,
Name: nsName,
Namespace: nsName,
ResourceRequest: true,
})
if err != nil {
panic(err)
}
if d != authorizer.DecisionAllow {
panic(fmt.Sprintf("expected decision Allow, got %d (0 Deny, 1 Allowed, 2 NoOpinion)", d))
}
})
}, gmeasure.SamplingConfig{N: 30, Duration: 2 * time.Minute})
// we'll sample the function up to 30 times or up to 2 minutes, whichever comes first.

// we sample a function repeatedly to get a statistically significant set of measurements
experiment.Sample(func(idx int) {
nsName := uns[0].GetName()
// measure how long it takes to produce a NoOpinion decision to a request
// and store the duration in a "authorization-no-opinion" measurement
experiment.MeasureDuration("authorization-noopinion", func() {
d, _, err := authzr.Authorize(ctx, authorizer.AttributesRecord{
User: &user.DefaultInfo{Name: username},
Verb: "get",
Resource: "namespaces",
APIGroup: corev1.GroupName,
APIVersion: corev1.SchemeGroupVersion.Version,
Name: nsName,
Namespace: nsName,
ResourceRequest: true,
})
if err != nil {
panic(err)
}
if d != authorizer.DecisionNoOpinion {
panic(fmt.Sprintf("expected decision NoOpinion, got %d (0 Deny, 1 Allowed, 2 NoOpinion)", d))
}
})
}, gmeasure.SamplingConfig{N: 30, Duration: 2 * time.Minute})
// we'll sample the function up to 30 times or up to 2 minutes, whichever comes first.

// we get the median listing duration from the experiment we just ran
repaginationStats := experiment.GetStats("listing")
medianDuration := repaginationStats.DurationFor(gmeasure.StatMedian)

// and assert that it hasn't changed much from ~100ms
Expect(medianDuration).To(BeNumerically("~", 100*time.Millisecond, 70*time.Millisecond))
})
})

func createResources(ctx context.Context, cli client.Client, user string, numAllowedNamespaces, numUnallowedNamespaces, numNonMatchingClusterRoles int) (error, []client.Object, []client.Object) {
// cluster scoped resources
mcr, nmcr := matchingClusterRoles(1), nonMatchingClusterRoles(numNonMatchingClusterRoles)
ans, uns := namespaces("allowed-tenant-", numAllowedNamespaces), namespaces("unallowed-tenant-", numUnallowedNamespaces)
if err := create(ctx, cli, slices.Concat(mcr, nmcr, ans, uns)); err != nil {
return fmt.Errorf("could not create cluster scoped resources: %w", err), nil, nil
}

// namespace scoped resources
atr := allowedTenants(user, ans, 10, "ClusterRole", mcr[0].GetName(), "ClusterRole", nmcr[0].GetName())
utr := unallowedTenants(user, uns, 10, "ClusterRole", mcr[0].GetName(), "ClusterRole", nmcr[0].GetName())
if err := create(ctx, cli, slices.Concat(atr, utr)); err != nil {
return fmt.Errorf("could not create namespaced scoped resources: %w", err), nil, nil
}
return nil, ans, uns
}

func create(ctx context.Context, cli client.Client, rr []client.Object) error {
for _, r := range rr {
if err := cli.Create(ctx, r); client.IgnoreAlreadyExists(err) != nil {
return err
}
}
return nil
}

func matchingClusterRoles(quantity int) []client.Object {
crr := make([]client.Object, quantity)
for i := range quantity {
cr := &rbacv1.ClusterRole{
ObjectMeta: metav1.ObjectMeta{
Name: fmt.Sprintf("perf-cluster-role-matching-%d", i),
},
Rules: []rbacv1.PolicyRule{
{
APIGroups: []string{corev1.GroupName},
Resources: []string{"namespaces"},
Verbs: []string{"get"},
},
},
}
crr[i] = cr
}
return crr
}

func nonMatchingClusterRoles(quantity int) []client.Object {
crr := make([]client.Object, quantity)
for i := range quantity {
cr := &rbacv1.ClusterRole{
ObjectMeta: metav1.ObjectMeta{
GenerateName: "perf-cluster-role-non-matching-",
},
}
crr[i] = cr
}
return crr
}

func namespaces(generateName string, quantity int) []client.Object {
rr := make([]client.Object, quantity)
for i := range quantity {
rr[i] = &corev1.Namespace{
ObjectMeta: metav1.ObjectMeta{
GenerateName: generateName,
Labels: map[string]string{
NamespaceTypeLabelKey: NamespaceTypeUserLabelValue,
},
},
}
}
return rr
}

func allowedTenants(user string, namespaces []client.Object, pollutingRoleBindings int, matchingRoleRefKind, matchingRoleRefName, nonMatchingRoleRefKind, nonMatchingRoleRefName string) []client.Object {
rr := make([]client.Object, 0, len(namespaces)*(pollutingRoleBindings+1))
for _, n := range namespaces {

// add access role binding
rr = append(rr, &rbacv1.RoleBinding{
ObjectMeta: metav1.ObjectMeta{
GenerateName: "allowed-tenant-",
Namespace: n.GetName(),
},
Subjects: []rbacv1.Subject{{Kind: "User", APIGroup: rbacv1.GroupName, Name: user}},
RoleRef: rbacv1.RoleRef{
APIGroup: rbacv1.GroupName,
Kind: matchingRoleRefKind,
Name: matchingRoleRefName,
},
})

// add pollution
for range pollutingRoleBindings {
rr = append(rr, &rbacv1.RoleBinding{
ObjectMeta: metav1.ObjectMeta{
GenerateName: "pollution-",
Namespace: n.GetName(),
},
Subjects: []rbacv1.Subject{{Kind: "User", APIGroup: rbacv1.GroupName, Name: user}},
RoleRef: rbacv1.RoleRef{
APIGroup: rbacv1.GroupName,
Kind: nonMatchingRoleRefKind,
Name: nonMatchingRoleRefName,
},
})
}
}
return rr
}

func unallowedTenants(user string, namespaces []client.Object, pollutingRoleBindings int, matchingRoleRefKind, matchingRoleRefName, nonMatchingRoleRefKind, nonMatchingRoleRefName string) []client.Object {
rr := make([]client.Object, 0, len(namespaces)*(pollutingRoleBindings+1))
for _, n := range namespaces {
// add access role binding
rr = append(rr, &rbacv1.RoleBinding{
ObjectMeta: metav1.ObjectMeta{
GenerateName: "non-allowed-tenant-",
Namespace: n.GetName(),
},
Subjects: []rbacv1.Subject{{Kind: "User", APIGroup: rbacv1.GroupName, Name: "not-the-perf-test-user"}},
RoleRef: rbacv1.RoleRef{
APIGroup: rbacv1.GroupName,
Kind: matchingRoleRefKind,
Name: matchingRoleRefName,
},
})

// add pollution
for range pollutingRoleBindings {
rr = append(rr, &rbacv1.RoleBinding{
ObjectMeta: metav1.ObjectMeta{
GenerateName: "pollution-",
Namespace: n.GetName(),
},
Subjects: []rbacv1.Subject{{Kind: "User", APIGroup: rbacv1.GroupName, Name: user}},
RoleRef: rbacv1.RoleRef{
APIGroup: rbacv1.GroupName,
Kind: nonMatchingRoleRefKind,
Name: nonMatchingRoleRefName,
},
})
}
}
return rr
}
Loading