From 6c37cfee11103cf667c396ceb3049b6421598b01 Mon Sep 17 00:00:00 2001 From: Nikola Jokic Date: Thu, 18 Jun 2026 15:40:08 +0200 Subject: [PATCH] wip --- Makefile | 14 +- ...toscalinglistener_controller_bench_test.go | 957 ++++++++++++++++++ .../autoscalingrunnerset_controller.go | 3 + ...oscalingrunnerset_controller_bench_test.go | 509 ++++++++++ .../benchmark_controller_utils_test.go | 221 ++++ .../ephemeralrunner_controller_bench_test.go | 896 ++++++++++++++++ ...phemeralrunnerset_controller_bench_test.go | 418 ++++++++ 7 files changed, 3014 insertions(+), 4 deletions(-) create mode 100644 controllers/actions.github.com/autoscalinglistener_controller_bench_test.go create mode 100644 controllers/actions.github.com/autoscalingrunnerset_controller_bench_test.go create mode 100644 controllers/actions.github.com/benchmark_controller_utils_test.go create mode 100644 controllers/actions.github.com/ephemeralrunner_controller_bench_test.go create mode 100644 controllers/actions.github.com/ephemeralrunnerset_controller_bench_test.go diff --git a/Makefile b/Makefile index c52fefd2..425f3794 100644 --- a/Makefile +++ b/Makefile @@ -78,6 +78,16 @@ test-with-deps: setup-envtest KUBEBUILDER_ASSETS="$$($(ENVTEST) use $(ENVTEST_K8S_VERSION) --bin-dir $(GOBIN) -p path)" \ make test +.PHONY: benchmark-actions-github-com +benchmark-actions-github-com: setup-envtest + @echo "Running reconcile benchmarks for actions.github.com..." + KUBEBUILDER_ASSETS="$$($(ENVTEST) use $(ENVTEST_K8S_VERSION) --bin-dir $(GOBIN) -p path)" \ + go test ./controllers/actions.github.com \ + -run '^$$' \ + -bench 'BenchmarkActionsGithub_Reconcile_.*' \ + -benchmem \ + -count=5 \ + -benchtime=1s # Build manager binary manager: generate fmt vet @@ -365,7 +375,6 @@ SHELLCHECK=$(TOOLS_PATH)/shellcheck # find or download envtest envtest: -ifeq (, $(shell which setup-envtest)) ifeq (, $(wildcard $(GOBIN)/setup-envtest)) @{ \ set -e ;\ @@ -377,9 +386,6 @@ ifeq (, $(wildcard $(GOBIN)/setup-envtest)) } endif ENVTEST=$(GOBIN)/setup-envtest -else -ENVTEST=$(shell which setup-envtest) -endif .PHONY: setup-envtest setup-envtest: envtest diff --git a/controllers/actions.github.com/autoscalinglistener_controller_bench_test.go b/controllers/actions.github.com/autoscalinglistener_controller_bench_test.go new file mode 100644 index 00000000..52fdc533 --- /dev/null +++ b/controllers/actions.github.com/autoscalinglistener_controller_bench_test.go @@ -0,0 +1,957 @@ +/* +Copyright 2020 The actions-runner-controller authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package actionsgithubcom + +import ( + "context" + "testing" + + actionsv1alpha1 "github.com/actions/actions-runner-controller/apis/actions.github.com/v1alpha1" + "github.com/actions/actions-runner-controller/controllers/actions.github.com/secretresolver" + "github.com/go-logr/logr" + corev1 "k8s.io/api/core/v1" + rbacv1 "k8s.io/api/rbac/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + clientgoscheme "k8s.io/client-go/kubernetes/scheme" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + logf "sigs.k8s.io/controller-runtime/pkg/log" +) + +// BenchmarkActionsGithub_Reconcile_AutoscalingListener_FinalizerAdd benchmarks adding finalizer to AutoscalingListener +func BenchmarkActionsGithub_Reconcile_AutoscalingListener_FinalizerAdd(b *testing.B) { + scheme := runtime.NewScheme() + _ = clientgoscheme.AddToScheme(scheme) + _ = actionsv1alpha1.AddToScheme(scheme) + + namespace := "test-ns" + listenerName := "test-listener" + arsName := "test-ars" + + // Create AutoscalingRunnerSet (required dependency) + ars := NewMinimalAutoscalingRunnerSet(namespace, arsName) + + // Create listener without finalizer + listener := NewMinimalAutoscalingListener(namespace, listenerName) + listener.Spec.AutoscalingRunnerSetNamespace = namespace + listener.Spec.AutoscalingRunnerSetName = arsName + listener.Finalizers = nil + + // Create secret for listener configuration + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-secret", + Namespace: namespace, + }, + Data: map[string][]byte{ + "github_token": []byte("fake-token"), + }, + } + listener.Spec.GitHubConfigSecret = secret.Name + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(ars, listener, secret). + WithStatusSubresource(&actionsv1alpha1.AutoscalingListener{}). + Build() + + secretResolver := secretresolver.New(fakeClient, NewFakeMultiClient()) + rb := ResourceBuilder{SecretResolver: secretResolver} + + reconciler := &AutoscalingListenerReconciler{ + Client: fakeClient, + Scheme: scheme, + Log: logf.Log.WithName("benchmark"), + ResourceBuilder: rb, + } + + ctx := context.Background() + req := ctrl.Request{ + NamespacedName: client.ObjectKey{ + Namespace: namespace, + Name: listenerName, + }, + } + + // Warmup + WarmupIteration(b, func() { + _, _ = reconciler.Reconcile(ctx, req) + }) + + for b.Loop() { + // Reset listener state (remove finalizer for each iteration) + updatedListener := &actionsv1alpha1.AutoscalingListener{} + _ = fakeClient.Get(ctx, client.ObjectKey{Namespace: namespace, Name: listenerName}, updatedListener) + updatedListener.Finalizers = nil + _ = fakeClient.Update(ctx, updatedListener) + + _, _ = reconciler.Reconcile(ctx, req) + } +} + +// BenchmarkActionsGithub_Reconcile_AutoscalingListener_FinalizerRemove benchmarks removing finalizer during deletion +func BenchmarkActionsGithub_Reconcile_AutoscalingListener_FinalizerRemove(b *testing.B) { + scheme := runtime.NewScheme() + _ = clientgoscheme.AddToScheme(scheme) + _ = actionsv1alpha1.AddToScheme(scheme) + + namespace := "test-ns" + listenerName := "test-listener" + arsName := "test-ars" + + // Create AutoscalingRunnerSet + ars := NewMinimalAutoscalingRunnerSet(namespace, arsName) + + // Create listener with finalizer and deletion timestamp + listener := NewMinimalAutoscalingListener(namespace, listenerName) + listener.Spec.AutoscalingRunnerSetNamespace = namespace + listener.Spec.AutoscalingRunnerSetName = arsName + listener.Finalizers = []string{autoscalingListenerFinalizerName} + now := metav1.Now() + listener.DeletionTimestamp = &now + + // Create secret + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-secret", + Namespace: namespace, + }, + Data: map[string][]byte{ + "github_token": []byte("fake-token"), + }, + } + listener.Spec.GitHubConfigSecret = secret.Name + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(ars, listener, secret). + WithStatusSubresource(&actionsv1alpha1.AutoscalingListener{}). + Build() + + secretResolver := secretresolver.New(fakeClient, NewFakeMultiClient()) + rb := ResourceBuilder{SecretResolver: secretResolver} + + reconciler := &AutoscalingListenerReconciler{ + Client: fakeClient, + Scheme: scheme, + Log: logf.Log.WithName("benchmark"), + ResourceBuilder: rb, + } + + ctx := context.Background() + req := ctrl.Request{ + NamespacedName: client.ObjectKey{ + Namespace: namespace, + Name: listenerName, + }, + } + + // Warmup + WarmupIteration(b, func() { + _, _ = reconciler.Reconcile(ctx, req) + }) + + for b.Loop() { + // Reset listener with finalizer + updatedListener := &actionsv1alpha1.AutoscalingListener{} + if err := fakeClient.Get(ctx, client.ObjectKey{Namespace: namespace, Name: listenerName}, updatedListener); err == nil { + updatedListener.Finalizers = []string{autoscalingListenerFinalizerName} + _ = fakeClient.Update(ctx, updatedListener) + } + + _, _ = reconciler.Reconcile(ctx, req) + } +} + +// BenchmarkActionsGithub_Reconcile_AutoscalingListener_MissingARSDeletion benchmarks deleting listener when AutoscalingRunnerSet is missing +func BenchmarkActionsGithub_Reconcile_AutoscalingListener_MissingARSDeletion(b *testing.B) { + scheme := runtime.NewScheme() + _ = clientgoscheme.AddToScheme(scheme) + _ = actionsv1alpha1.AddToScheme(scheme) + + namespace := "test-ns" + listenerName := "test-listener" + arsName := "test-ars" + + // Create listener with finalizer but NO AutoscalingRunnerSet + listener := NewMinimalAutoscalingListener(namespace, listenerName) + listener.Spec.AutoscalingRunnerSetNamespace = namespace + listener.Spec.AutoscalingRunnerSetName = arsName + listener.Finalizers = []string{autoscalingListenerFinalizerName} + + // Create secret + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-secret", + Namespace: namespace, + }, + Data: map[string][]byte{ + "github_token": []byte("fake-token"), + }, + } + listener.Spec.GitHubConfigSecret = secret.Name + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(listener, secret). + WithStatusSubresource(&actionsv1alpha1.AutoscalingListener{}). + Build() + + secretResolver := secretresolver.New(fakeClient, NewFakeMultiClient()) + rb := ResourceBuilder{SecretResolver: secretResolver} + + reconciler := &AutoscalingListenerReconciler{ + Client: fakeClient, + Scheme: scheme, + Log: logf.Log.WithName("benchmark"), + ResourceBuilder: rb, + } + + ctx := context.Background() + req := ctrl.Request{ + NamespacedName: client.ObjectKey{ + Namespace: namespace, + Name: listenerName, + }, + } + + // Warmup + WarmupIteration(b, func() { + _, _ = reconciler.Reconcile(ctx, req) + }) + + for b.Loop() { + // Recreate listener for each iteration + _ = fakeClient.Delete(ctx, listener) + newListener := NewMinimalAutoscalingListener(namespace, listenerName) + newListener.Spec.AutoscalingRunnerSetNamespace = namespace + newListener.Spec.AutoscalingRunnerSetName = arsName + newListener.Spec.GitHubConfigSecret = secret.Name + newListener.Finalizers = []string{autoscalingListenerFinalizerName} + _ = fakeClient.Create(ctx, newListener) + + _, _ = reconciler.Reconcile(ctx, req) + } +} + +// BenchmarkActionsGithub_Reconcile_AutoscalingListener_CreateServiceAccount benchmarks creating ServiceAccount +func BenchmarkActionsGithub_Reconcile_AutoscalingListener_CreateServiceAccount(b *testing.B) { + scheme := runtime.NewScheme() + _ = clientgoscheme.AddToScheme(scheme) + _ = actionsv1alpha1.AddToScheme(scheme) + + namespace := "test-ns" + listenerName := "test-listener" + arsName := "test-ars" + + // Create AutoscalingRunnerSet + ars := NewMinimalAutoscalingRunnerSet(namespace, arsName) + + // Create listener with finalizer (already added) + listener := NewMinimalAutoscalingListener(namespace, listenerName) + listener.Spec.AutoscalingRunnerSetNamespace = namespace + listener.Spec.AutoscalingRunnerSetName = arsName + listener.Finalizers = []string{autoscalingListenerFinalizerName} + + // Create secret + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-secret", + Namespace: namespace, + }, + Data: map[string][]byte{ + "github_token": []byte("fake-token"), + }, + } + listener.Spec.GitHubConfigSecret = secret.Name + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(ars, listener, secret). + WithStatusSubresource(&actionsv1alpha1.AutoscalingListener{}). + Build() + + secretResolver := secretresolver.New(fakeClient, NewFakeMultiClient()) + rb := ResourceBuilder{SecretResolver: secretResolver} + + reconciler := &AutoscalingListenerReconciler{ + Client: fakeClient, + Scheme: scheme, + Log: logf.Log.WithName("benchmark"), + ResourceBuilder: rb, + } + + ctx := context.Background() + req := ctrl.Request{ + NamespacedName: client.ObjectKey{ + Namespace: namespace, + Name: listenerName, + }, + } + + // Warmup + WarmupIteration(b, func() { + _, _ = reconciler.Reconcile(ctx, req) + }) + + b.ResetTimer() + for b.Loop() { + // Delete ServiceAccount to trigger creation path + sa := &corev1.ServiceAccount{ + ObjectMeta: metav1.ObjectMeta{ + Name: listenerName, + Namespace: namespace, + }, + } + _ = fakeClient.Delete(ctx, sa) + + _, _ = reconciler.Reconcile(ctx, req) + } +} + +// BenchmarkActionsGithub_Reconcile_AutoscalingListener_UpdateServiceAccount benchmarks updating ServiceAccount +func BenchmarkActionsGithub_Reconcile_AutoscalingListener_UpdateServiceAccount(b *testing.B) { + scheme := runtime.NewScheme() + _ = clientgoscheme.AddToScheme(scheme) + _ = actionsv1alpha1.AddToScheme(scheme) + + namespace := "test-ns" + listenerName := "test-listener" + arsName := "test-ars" + + // Create AutoscalingRunnerSet + ars := NewMinimalAutoscalingRunnerSet(namespace, arsName) + + // Create listener with finalizer + listener := NewMinimalAutoscalingListener(namespace, listenerName) + listener.Spec.AutoscalingRunnerSetNamespace = namespace + listener.Spec.AutoscalingRunnerSetName = arsName + listener.Finalizers = []string{autoscalingListenerFinalizerName} + + // Create ServiceAccount with outdated labels + sa := &corev1.ServiceAccount{ + ObjectMeta: metav1.ObjectMeta{ + Name: listenerName, + Namespace: namespace, + Labels: map[string]string{ + "outdated-label": "old-value", + }, + }, + } + + // Create secret + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-secret", + Namespace: namespace, + }, + Data: map[string][]byte{ + "github_token": []byte("fake-token"), + }, + } + listener.Spec.GitHubConfigSecret = secret.Name + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(ars, listener, sa, secret). + WithStatusSubresource(&actionsv1alpha1.AutoscalingListener{}). + Build() + + secretResolver := secretresolver.New(fakeClient, NewFakeMultiClient()) + rb := ResourceBuilder{SecretResolver: secretResolver} + + reconciler := &AutoscalingListenerReconciler{ + Client: fakeClient, + Scheme: scheme, + Log: logr.Discard(), + ResourceBuilder: rb, + } + + ctx := context.Background() + req := ctrl.Request{ + NamespacedName: client.ObjectKey{ + Namespace: namespace, + Name: listenerName, + }, + } + + // Warmup + WarmupIteration(b, func() { + _, _ = reconciler.Reconcile(ctx, req) + }) + + for b.Loop() { + // Reset ServiceAccount labels to trigger update + updatedSA := &corev1.ServiceAccount{} + _ = fakeClient.Get(ctx, client.ObjectKey{Namespace: namespace, Name: listenerName}, updatedSA) + updatedSA.Labels = map[string]string{"outdated-label": "old-value"} + _ = fakeClient.Update(ctx, updatedSA) + + _, _ = reconciler.Reconcile(ctx, req) + } +} + +// BenchmarkActionsGithub_Reconcile_AutoscalingListener_CreateRole benchmarks creating Role +func BenchmarkActionsGithub_Reconcile_AutoscalingListener_CreateRole(b *testing.B) { + scheme := runtime.NewScheme() + _ = clientgoscheme.AddToScheme(scheme) + _ = actionsv1alpha1.AddToScheme(scheme) + + namespace := "test-ns" + listenerName := "test-listener" + arsName := "test-ars" + + // Create AutoscalingRunnerSet + ars := NewMinimalAutoscalingRunnerSet(namespace, arsName) + + // Create listener with finalizer + listener := NewMinimalAutoscalingListener(namespace, listenerName) + listener.Spec.AutoscalingRunnerSetNamespace = namespace + listener.Spec.AutoscalingRunnerSetName = arsName + listener.Finalizers = []string{autoscalingListenerFinalizerName} + + // Create ServiceAccount (prerequisite) + sa := &corev1.ServiceAccount{ + ObjectMeta: metav1.ObjectMeta{ + Name: listenerName, + Namespace: namespace, + }, + } + + // Create secret + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-secret", + Namespace: namespace, + }, + Data: map[string][]byte{ + "github_token": []byte("fake-token"), + }, + } + listener.Spec.GitHubConfigSecret = secret.Name + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(ars, listener, sa, secret). + WithStatusSubresource(&actionsv1alpha1.AutoscalingListener{}). + Build() + + secretResolver := secretresolver.New(fakeClient, NewFakeMultiClient()) + rb := ResourceBuilder{SecretResolver: secretResolver} + + reconciler := &AutoscalingListenerReconciler{ + Client: fakeClient, + Scheme: scheme, + Log: logr.Discard(), + ResourceBuilder: rb, + } + + ctx := context.Background() + req := ctrl.Request{ + NamespacedName: client.ObjectKey{ + Namespace: namespace, + Name: listenerName, + }, + } + + // Warmup + WarmupIteration(b, func() { + _, _ = reconciler.Reconcile(ctx, req) + }) + + b.ResetTimer() + for i := 0; i < b.N; i++ { + // Delete Role to trigger creation path + role := &rbacv1.Role{ + ObjectMeta: metav1.ObjectMeta{ + Name: listenerName, + Namespace: namespace, + }, + } + _ = fakeClient.Delete(ctx, role) + + _, _ = reconciler.Reconcile(ctx, req) + } +} + +// BenchmarkActionsGithub_Reconcile_AutoscalingListener_UpdateRole benchmarks updating Role +func BenchmarkActionsGithub_Reconcile_AutoscalingListener_UpdateRole(b *testing.B) { + scheme := runtime.NewScheme() + _ = clientgoscheme.AddToScheme(scheme) + _ = actionsv1alpha1.AddToScheme(scheme) + + namespace := "test-ns" + listenerName := "test-listener" + arsName := "test-ars" + + // Create AutoscalingRunnerSet + ars := NewMinimalAutoscalingRunnerSet(namespace, arsName) + + // Create listener with finalizer + listener := NewMinimalAutoscalingListener(namespace, listenerName) + listener.Spec.AutoscalingRunnerSetNamespace = namespace + listener.Spec.AutoscalingRunnerSetName = arsName + listener.Finalizers = []string{autoscalingListenerFinalizerName} + + // Create ServiceAccount + sa := &corev1.ServiceAccount{ + ObjectMeta: metav1.ObjectMeta{ + Name: listenerName, + Namespace: namespace, + }, + } + + // Create Role with outdated labels + role := &rbacv1.Role{ + ObjectMeta: metav1.ObjectMeta{ + Name: listenerName, + Namespace: namespace, + Labels: map[string]string{ + "outdated-label": "old-value", + }, + }, + Rules: []rbacv1.PolicyRule{ + { + APIGroups: []string{""}, + Resources: []string{"pods"}, + Verbs: []string{"get"}, + }, + }, + } + + // Create secret + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-secret", + Namespace: namespace, + }, + Data: map[string][]byte{ + "github_token": []byte("fake-token"), + }, + } + listener.Spec.GitHubConfigSecret = secret.Name + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(ars, listener, sa, role, secret). + WithStatusSubresource(&actionsv1alpha1.AutoscalingListener{}). + Build() + + secretResolver := secretresolver.New(fakeClient, NewFakeMultiClient()) + rb := ResourceBuilder{SecretResolver: secretResolver} + + reconciler := &AutoscalingListenerReconciler{ + Client: fakeClient, + Scheme: scheme, + Log: logr.Discard(), + ResourceBuilder: rb, + } + + ctx := context.Background() + req := ctrl.Request{ + NamespacedName: client.ObjectKey{ + Namespace: namespace, + Name: listenerName, + }, + } + + // Warmup + WarmupIteration(b, func() { + _, _ = reconciler.Reconcile(ctx, req) + }) + + b.ResetTimer() + for i := 0; i < b.N; i++ { + // Reset Role labels to trigger update + updatedRole := &rbacv1.Role{} + _ = fakeClient.Get(ctx, client.ObjectKey{Namespace: namespace, Name: listenerName}, updatedRole) + updatedRole.Labels = map[string]string{"outdated-label": "old-value"} + _ = fakeClient.Update(ctx, updatedRole) + + _, _ = reconciler.Reconcile(ctx, req) + } +} + +// BenchmarkActionsGithub_Reconcile_AutoscalingListener_CreateRoleBinding benchmarks creating RoleBinding +func BenchmarkActionsGithub_Reconcile_AutoscalingListener_CreateRoleBinding(b *testing.B) { + scheme := runtime.NewScheme() + _ = clientgoscheme.AddToScheme(scheme) + _ = actionsv1alpha1.AddToScheme(scheme) + + namespace := "test-ns" + listenerName := "test-listener" + arsName := "test-ars" + + // Create AutoscalingRunnerSet + ars := NewMinimalAutoscalingRunnerSet(namespace, arsName) + + // Create listener with finalizer + listener := NewMinimalAutoscalingListener(namespace, listenerName) + listener.Spec.AutoscalingRunnerSetNamespace = namespace + listener.Spec.AutoscalingRunnerSetName = arsName + listener.Finalizers = []string{autoscalingListenerFinalizerName} + + // Create ServiceAccount + sa := &corev1.ServiceAccount{ + ObjectMeta: metav1.ObjectMeta{ + Name: listenerName, + Namespace: namespace, + }, + } + + // Create Role + role := &rbacv1.Role{ + ObjectMeta: metav1.ObjectMeta{ + Name: listenerName, + Namespace: namespace, + }, + Rules: []rbacv1.PolicyRule{}, + } + + // Create secret + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-secret", + Namespace: namespace, + }, + Data: map[string][]byte{ + "github_token": []byte("fake-token"), + }, + } + listener.Spec.GitHubConfigSecret = secret.Name + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(ars, listener, sa, role, secret). + WithStatusSubresource(&actionsv1alpha1.AutoscalingListener{}). + Build() + + secretResolver := secretresolver.New(fakeClient, NewFakeMultiClient()) + rb := ResourceBuilder{SecretResolver: secretResolver} + + reconciler := &AutoscalingListenerReconciler{ + Client: fakeClient, + Scheme: scheme, + Log: logr.Discard(), + ResourceBuilder: rb, + } + + ctx := context.Background() + req := ctrl.Request{ + NamespacedName: client.ObjectKey{ + Namespace: namespace, + Name: listenerName, + }, + } + + // Warmup + WarmupIteration(b, func() { + _, _ = reconciler.Reconcile(ctx, req) + }) + + b.ResetTimer() + for i := 0; i < b.N; i++ { + // Delete RoleBinding to trigger creation path + rb := &rbacv1.RoleBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: listenerName, + Namespace: namespace, + }, + } + _ = fakeClient.Delete(ctx, rb) + + _, _ = reconciler.Reconcile(ctx, req) + } +} + +// BenchmarkActionsGithub_Reconcile_AutoscalingListener_UpdateRoleBinding benchmarks updating RoleBinding +func BenchmarkActionsGithub_Reconcile_AutoscalingListener_UpdateRoleBinding(b *testing.B) { + scheme := runtime.NewScheme() + _ = clientgoscheme.AddToScheme(scheme) + _ = actionsv1alpha1.AddToScheme(scheme) + + namespace := "test-ns" + listenerName := "test-listener" + arsName := "test-ars" + + // Create AutoscalingRunnerSet + ars := NewMinimalAutoscalingRunnerSet(namespace, arsName) + + // Create listener with finalizer + listener := NewMinimalAutoscalingListener(namespace, listenerName) + listener.Spec.AutoscalingRunnerSetNamespace = namespace + listener.Spec.AutoscalingRunnerSetName = arsName + listener.Finalizers = []string{autoscalingListenerFinalizerName} + + // Create ServiceAccount + sa := &corev1.ServiceAccount{ + ObjectMeta: metav1.ObjectMeta{ + Name: listenerName, + Namespace: namespace, + }, + } + + // Create Role + role := &rbacv1.Role{ + ObjectMeta: metav1.ObjectMeta{ + Name: listenerName, + Namespace: namespace, + }, + Rules: []rbacv1.PolicyRule{}, + } + + // Create RoleBinding with outdated labels + roleBinding := &rbacv1.RoleBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: listenerName, + Namespace: namespace, + Labels: map[string]string{ + "outdated-label": "old-value", + }, + }, + RoleRef: rbacv1.RoleRef{ + APIGroup: "rbac.authorization.k8s.io", + Kind: "Role", + Name: listenerName, + }, + Subjects: []rbacv1.Subject{ + { + Kind: "ServiceAccount", + Name: listenerName, + Namespace: namespace, + }, + }, + } + + // Create secret + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-secret", + Namespace: namespace, + }, + Data: map[string][]byte{ + "github_token": []byte("fake-token"), + }, + } + listener.Spec.GitHubConfigSecret = secret.Name + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(ars, listener, sa, role, roleBinding, secret). + WithStatusSubresource(&actionsv1alpha1.AutoscalingListener{}). + Build() + + secretResolver := secretresolver.New(fakeClient, NewFakeMultiClient()) + rb := ResourceBuilder{SecretResolver: secretResolver} + + reconciler := &AutoscalingListenerReconciler{ + Client: fakeClient, + Scheme: scheme, + Log: logr.Discard(), + ResourceBuilder: rb, + } + + ctx := context.Background() + req := ctrl.Request{ + NamespacedName: client.ObjectKey{ + Namespace: namespace, + Name: listenerName, + }, + } + + // Warmup + WarmupIteration(b, func() { + _, _ = reconciler.Reconcile(ctx, req) + }) + + b.ResetTimer() + for i := 0; i < b.N; i++ { + // Reset RoleBinding labels to trigger update + updatedRB := &rbacv1.RoleBinding{} + _ = fakeClient.Get(ctx, client.ObjectKey{Namespace: namespace, Name: listenerName}, updatedRB) + updatedRB.Labels = map[string]string{"outdated-label": "old-value"} + _ = fakeClient.Update(ctx, updatedRB) + + _, _ = reconciler.Reconcile(ctx, req) + } +} + +// BenchmarkActionsGithub_Reconcile_AutoscalingListener_SteadyState benchmarks no-op steady-state reconciliation +func BenchmarkActionsGithub_Reconcile_AutoscalingListener_SteadyState(b *testing.B) { + scheme := runtime.NewScheme() + _ = clientgoscheme.AddToScheme(scheme) + _ = actionsv1alpha1.AddToScheme(scheme) + + namespace := "test-ns" + listenerName := "test-listener" + arsName := "test-ars" + + // Create AutoscalingRunnerSet + ars := NewMinimalAutoscalingRunnerSet(namespace, arsName) + + // Create fully configured listener + listener := NewMinimalAutoscalingListener(namespace, listenerName) + listener.Spec.AutoscalingRunnerSetNamespace = namespace + listener.Spec.AutoscalingRunnerSetName = arsName + listener.Finalizers = []string{autoscalingListenerFinalizerName} + + // Create all required resources (steady state) + sa := &corev1.ServiceAccount{ + ObjectMeta: metav1.ObjectMeta{ + Name: listenerName, + Namespace: namespace, + Labels: map[string]string{ + "actions.github.com/autoscaling-listener-name": listenerName, + "actions.github.com/autoscaling-listener-namespace": namespace, + }, + }, + } + + role := &rbacv1.Role{ + ObjectMeta: metav1.ObjectMeta{ + Name: listenerName, + Namespace: namespace, + Labels: map[string]string{ + "auto-scaling-listener-name": listenerName, + "auto-scaling-listener-namespace": namespace, + }, + }, + Rules: rulesForListenerRole([]string{listener.Spec.EphemeralRunnerSetName}), + } + + roleBinding := &rbacv1.RoleBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: listenerName, + Namespace: namespace, + Labels: map[string]string{ + "auto-scaling-listener-name": listenerName, + "auto-scaling-listener-namespace": namespace, + }, + }, + RoleRef: rbacv1.RoleRef{ + APIGroup: "rbac.authorization.k8s.io", + Kind: "Role", + Name: listenerName, + }, + Subjects: []rbacv1.Subject{ + { + Kind: "ServiceAccount", + Name: listenerName, + Namespace: namespace, + }, + }, + } + + // Create config secret + configSecret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: scaleSetListenerConfigName(listener), + Namespace: namespace, + Labels: map[string]string{ + "auto-scaling-listener-name": listenerName, + }, + }, + Data: map[string][]byte{ + "listener": []byte(`{"url":"https://github.com/example/repo","token":"fake-token"}`), + }, + } + + // Create github config secret + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-secret", + Namespace: namespace, + }, + Data: map[string][]byte{ + "github_token": []byte("fake-token"), + }, + } + listener.Spec.GitHubConfigSecret = secret.Name + + // Create listener pod (running state) + listenerPod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: listenerName, + Namespace: namespace, + Labels: map[string]string{ + "auto-scaling-listener-name": listenerName, + }, + Annotations: map[string]string{ + AnnotationKeyIntegrityHash: "deterministic-hash", + }, + }, + Spec: corev1.PodSpec{ + ServiceAccountName: listenerName, + Containers: []corev1.Container{ + { + Name: autoscalingListenerContainerName, + Image: listener.Spec.Image, + }, + }, + }, + Status: corev1.PodStatus{ + Phase: corev1.PodRunning, + ContainerStatuses: []corev1.ContainerStatus{ + { + Name: autoscalingListenerContainerName, + State: corev1.ContainerState{ + Running: &corev1.ContainerStateRunning{ + StartedAt: metav1.Now(), + }, + }, + }, + }, + }, + } + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(ars, listener, sa, role, roleBinding, configSecret, secret, listenerPod). + WithStatusSubresource(&actionsv1alpha1.AutoscalingListener{}). + Build() + + secretResolver := secretresolver.New(fakeClient, NewFakeMultiClient()) + rb := ResourceBuilder{SecretResolver: secretResolver} + + reconciler := &AutoscalingListenerReconciler{ + Client: fakeClient, + Scheme: scheme, + Log: logr.Discard(), + ResourceBuilder: rb, + } + + ctx := context.Background() + req := ctrl.Request{ + NamespacedName: client.ObjectKey{ + Namespace: namespace, + Name: listenerName, + }, + } + + // Warmup + WarmupIteration(b, func() { + _, _ = reconciler.Reconcile(ctx, req) + }) + + b.ResetTimer() + for i := 0; i < b.N; i++ { + // No-op steady state - everything is in sync + _, _ = reconciler.Reconcile(ctx, req) + } +} diff --git a/controllers/actions.github.com/autoscalingrunnerset_controller.go b/controllers/actions.github.com/autoscalingrunnerset_controller.go index 0bec0aab..0e90f3db 100644 --- a/controllers/actions.github.com/autoscalingrunnerset_controller.go +++ b/controllers/actions.github.com/autoscalingrunnerset_controller.go @@ -701,6 +701,9 @@ func (r *AutoscalingRunnerSetReconciler) updateRunnerScaleSetName(ctx context.Co logger.Info("Updating runner scale set name as an annotation") original := autoscalingRunnerSet.DeepCopy() + if autoscalingRunnerSet.Annotations == nil { + autoscalingRunnerSet.Annotations = make(map[string]string, 1) + } autoscalingRunnerSet.Annotations[AnnotationKeyGitHubRunnerScaleSetName] = updatedRunnerScaleSet.Name if err := r.Patch(ctx, autoscalingRunnerSet, client.MergeFrom(original)); err != nil { logger.Error(err, "Failed to update runner scale set name annotation") diff --git a/controllers/actions.github.com/autoscalingrunnerset_controller_bench_test.go b/controllers/actions.github.com/autoscalingrunnerset_controller_bench_test.go new file mode 100644 index 00000000..f63e5765 --- /dev/null +++ b/controllers/actions.github.com/autoscalingrunnerset_controller_bench_test.go @@ -0,0 +1,509 @@ +/* +Copyright 2020 The actions-runner-controller authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package actionsgithubcom + +import ( + "context" + "testing" + + actionsv1alpha1 "github.com/actions/actions-runner-controller/apis/actions.github.com/v1alpha1" + "github.com/actions/actions-runner-controller/build" + scalefake "github.com/actions/actions-runner-controller/controllers/actions.github.com/multiclient/fake" + "github.com/actions/actions-runner-controller/controllers/actions.github.com/secretresolver" + "github.com/actions/scaleset" + "github.com/go-logr/logr" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +// BenchmarkActionsGithub_Reconcile_AutoscalingRunnerSet_AddFinalizer benchmarks the reconcile loop +// when adding a finalizer to a new AutoscalingRunnerSet +func BenchmarkActionsGithub_Reconcile_AutoscalingRunnerSet_AddFinalizer(b *testing.B) { + ctx := context.Background() + scheme := runtime.NewScheme() + _ = actionsv1alpha1.AddToScheme(scheme) + _ = corev1.AddToScheme(scheme) + + // Create fake secret + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-secret", + Namespace: "test-ns", + }, + Data: map[string][]byte{ + "github_token": []byte("test-token"), + }, + } + + // Create minimal AutoscalingRunnerSet without finalizer + ars := NewMinimalAutoscalingRunnerSet("test-ns", "test-ars") + ars.Spec.GitHubConfigSecret = "test-secret" + ars.Labels = map[string]string{ + LabelKeyKubernetesVersion: build.Version, + } + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(secret, ars). + WithStatusSubresource(&actionsv1alpha1.AutoscalingRunnerSet{}). + Build() + + reconciler := &AutoscalingRunnerSetReconciler{ + Client: fakeClient, + Scheme: scheme, + Log: logr.Discard(), + ControllerNamespace: "test-ns", + ResourceBuilder: ResourceBuilder{ + SecretResolver: secretresolver.New(fakeClient, NewFakeMultiClient()), + }, + } + + req := ctrl.Request{ + NamespacedName: types.NamespacedName{ + Namespace: "test-ns", + Name: "test-ars", + }, + } + + for b.Loop() { + b.StopTimer() + // Reset the AutoscalingRunnerSet to initial state (no finalizer) + ars := NewMinimalAutoscalingRunnerSet("test-ns", "test-ars") + ars.Spec.GitHubConfigSecret = "test-secret" + ars.Labels = map[string]string{ + LabelKeyKubernetesVersion: build.Version, + } + ars.Annotations = map[string]string{ + AnnotationKeyIntegrityHash: autoscalingRunnerSetIntegrityHash(ars), + } + ResetBenchmarkAutoscalingRunnerSet(b, ctx, fakeClient, ars) + b.StartTimer() + + _, _ = reconciler.Reconcile(ctx, req) + } +} + +// BenchmarkActionsGithub_Reconcile_AutoscalingRunnerSet_RemoveFinalizer benchmarks the reconcile loop +// when removing a finalizer during deletion +func BenchmarkActionsGithub_Reconcile_AutoscalingRunnerSet_RemoveFinalizer(b *testing.B) { + ctx := context.Background() + scheme := runtime.NewScheme() + _ = actionsv1alpha1.AddToScheme(scheme) + _ = corev1.AddToScheme(scheme) + + // Create fake secret + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-secret", + Namespace: "test-ns", + }, + Data: map[string][]byte{ + "github_token": []byte("test-token"), + }, + } + + // Create AutoscalingRunnerSet with finalizer and deletion timestamp + ars := NewMinimalAutoscalingRunnerSet("test-ns", "test-ars") + ars.Spec.GitHubConfigSecret = "test-secret" + ars.Labels = map[string]string{ + LabelKeyKubernetesVersion: build.Version, + } + ars.Finalizers = []string{autoscalingRunnerSetFinalizerName} + now := metav1.Now() + ars.DeletionTimestamp = &now + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(secret, ars). + WithStatusSubresource(&actionsv1alpha1.AutoscalingRunnerSet{}). + Build() + + reconciler := &AutoscalingRunnerSetReconciler{ + Client: fakeClient, + Scheme: scheme, + Log: logr.Discard(), + ControllerNamespace: "test-ns", + ResourceBuilder: ResourceBuilder{ + SecretResolver: secretresolver.New(fakeClient, NewFakeMultiClient()), + }, + } + + req := ctrl.Request{ + NamespacedName: types.NamespacedName{ + Namespace: "test-ns", + Name: "test-ars", + }, + } + + for b.Loop() { + b.StopTimer() + // Reset the AutoscalingRunnerSet with finalizer + ars := NewMinimalAutoscalingRunnerSet("test-ns", "test-ars") + ars.Spec.GitHubConfigSecret = "test-secret" + ars.Labels = map[string]string{ + LabelKeyKubernetesVersion: build.Version, + } + ars.Finalizers = []string{autoscalingRunnerSetFinalizerName} + now := metav1.Now() + ars.DeletionTimestamp = &now + _ = fakeClient.Update(ctx, ars) + b.StartTimer() + + _, _ = reconciler.Reconcile(ctx, req) + } +} + +// BenchmarkActionsGithub_Reconcile_AutoscalingRunnerSet_CreateScaleSet benchmarks the reconcile loop +// when creating a runner scale set +func BenchmarkActionsGithub_Reconcile_AutoscalingRunnerSet_CreateScaleSet(b *testing.B) { + ctx := context.Background() + scheme := runtime.NewScheme() + _ = actionsv1alpha1.AddToScheme(scheme) + _ = corev1.AddToScheme(scheme) + + // Create fake secret + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-secret", + Namespace: "test-ns", + }, + Data: map[string][]byte{ + "github_token": []byte("test-token"), + }, + } + + // Create AutoscalingRunnerSet with finalizer but no scale set annotations + ars := NewMinimalAutoscalingRunnerSet("test-ns", "test-ars") + ars.Spec.GitHubConfigSecret = "test-secret" + ars.Labels = map[string]string{ + LabelKeyKubernetesVersion: build.Version, + } + ars.Finalizers = []string{autoscalingRunnerSetFinalizerName} + ars.Spec.RunnerGroup = "default" + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(secret, ars). + WithStatusSubresource(&actionsv1alpha1.AutoscalingRunnerSet{}). + Build() + + // Create fake scale client with runner group and scale set creation support + fakeScaleClient := scalefake.NewMultiClient( + scalefake.WithClient( + scalefake.NewClient( + scalefake.WithGetRunnerGroupByNameFunc(func(ctx context.Context, groupName string) (*scaleset.RunnerGroup, error) { + return &scaleset.RunnerGroup{ID: 1, Name: groupName}, nil + }), + scalefake.WithCreateRunnerScaleSetFunc(func(ctx context.Context, rs *scaleset.RunnerScaleSet) (*scaleset.RunnerScaleSet, error) { + return &scaleset.RunnerScaleSet{ + ID: 1, + Name: rs.Name, + RunnerGroupID: rs.RunnerGroupID, + RunnerGroupName: "default", + }, nil + }), + scalefake.WithGetRunnerScaleSet(nil, nil), + ), + ), + ) + + reconciler := &AutoscalingRunnerSetReconciler{ + Client: fakeClient, + Scheme: scheme, + Log: logr.Discard(), + ControllerNamespace: "test-ns", + ResourceBuilder: ResourceBuilder{ + SecretResolver: secretresolver.New(fakeClient, fakeScaleClient), + }, + } + + req := ctrl.Request{ + NamespacedName: types.NamespacedName{ + Namespace: "test-ns", + Name: "test-ars", + }, + } + + for b.Loop() { + b.StopTimer() + // Reset the AutoscalingRunnerSet to state before scale set creation + ars := NewMinimalAutoscalingRunnerSet("test-ns", "test-ars") + ars.Spec.GitHubConfigSecret = "test-secret" + ars.Labels = map[string]string{ + LabelKeyKubernetesVersion: build.Version, + } + ars.Finalizers = []string{autoscalingRunnerSetFinalizerName} + ars.Spec.RunnerGroup = "default" + // Remove scale set annotations to trigger creation + ars.Annotations = nil + ResetBenchmarkAutoscalingRunnerSet(b, ctx, fakeClient, ars) + b.StartTimer() + + _, _ = reconciler.Reconcile(ctx, req) + } +} + +// BenchmarkActionsGithub_Reconcile_AutoscalingRunnerSet_OutdatedCleanup benchmarks the reconcile loop +// when cleaning up outdated resources +func BenchmarkActionsGithub_Reconcile_AutoscalingRunnerSet_OutdatedCleanup(b *testing.B) { + ctx := context.Background() + scheme := runtime.NewScheme() + _ = actionsv1alpha1.AddToScheme(scheme) + _ = corev1.AddToScheme(scheme) + + // Create fake secret + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-secret", + Namespace: "test-ns", + }, + Data: map[string][]byte{ + "github_token": []byte("test-token"), + }, + } + + // Create AutoscalingRunnerSet in outdated phase + ars := NewMinimalAutoscalingRunnerSet("test-ns", "test-ars") + ars.Spec.GitHubConfigSecret = "test-secret" + ars.Labels = map[string]string{ + LabelKeyKubernetesVersion: build.Version, + } + ars.Finalizers = []string{autoscalingRunnerSetFinalizerName} + ars.Status.Phase = actionsv1alpha1.AutoscalingRunnerSetPhaseOutdated + + // Create AutoscalingListener to be cleaned up + listener := NewMinimalAutoscalingListener("test-ns", "test-ars-listener") + listener.Spec.AutoscalingRunnerSetNamespace = "test-ns" + listener.Spec.AutoscalingRunnerSetName = "test-ars" + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(secret, ars, listener). + WithStatusSubresource(&actionsv1alpha1.AutoscalingRunnerSet{}, &actionsv1alpha1.AutoscalingListener{}). + Build() + + reconciler := &AutoscalingRunnerSetReconciler{ + Client: fakeClient, + Scheme: scheme, + Log: logr.Discard(), + ControllerNamespace: "test-ns", + ResourceBuilder: ResourceBuilder{ + SecretResolver: secretresolver.New(fakeClient, NewFakeMultiClient()), + }, + } + + req := ctrl.Request{ + NamespacedName: types.NamespacedName{ + Namespace: "test-ns", + Name: "test-ars", + }, + } + + for b.Loop() { + b.StopTimer() + // Reset the AutoscalingRunnerSet to outdated state + ars := NewMinimalAutoscalingRunnerSet("test-ns", "test-ars") + ars.Spec.GitHubConfigSecret = "test-secret" + ars.Labels = map[string]string{ + LabelKeyKubernetesVersion: build.Version, + } + ars.Finalizers = []string{autoscalingRunnerSetFinalizerName} + ars.Status.Phase = actionsv1alpha1.AutoscalingRunnerSetPhaseOutdated + ResetBenchmarkAutoscalingRunnerSet(b, ctx, fakeClient, ars) + + // Recreate listener + listener := NewMinimalAutoscalingListener("test-ns", "test-ars-listener") + listener.Spec.AutoscalingRunnerSetNamespace = "test-ns" + listener.Spec.AutoscalingRunnerSetName = "test-ars" + _ = fakeClient.Create(ctx, listener) + b.StartTimer() + + _, _ = reconciler.Reconcile(ctx, req) + } +} + +// BenchmarkActionsGithub_Reconcile_AutoscalingRunnerSet_CreateEphemeralRunnerSet benchmarks the reconcile loop +// when creating an EphemeralRunnerSet +func BenchmarkActionsGithub_Reconcile_AutoscalingRunnerSet_CreateEphemeralRunnerSet(b *testing.B) { + ctx := context.Background() + scheme := runtime.NewScheme() + _ = actionsv1alpha1.AddToScheme(scheme) + _ = corev1.AddToScheme(scheme) + + // Create fake secret + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-secret", + Namespace: "test-ns", + }, + Data: map[string][]byte{ + "github_token": []byte("test-token"), + }, + } + + // Create AutoscalingRunnerSet with finalizer and scale set ID but no EphemeralRunnerSet + ars := NewMinimalAutoscalingRunnerSet("test-ns", "test-ars") + ars.Spec.GitHubConfigSecret = "test-secret" + ars.Labels = map[string]string{ + LabelKeyKubernetesVersion: build.Version, + } + ars.Finalizers = []string{autoscalingRunnerSetFinalizerName} + ars.Annotations = map[string]string{ + runnerScaleSetIDAnnotationKey: "1", + AnnotationKeyGitHubRunnerScaleSetName: "test-scale-set", + AnnotationKeyGitHubRunnerGroupName: "default", + AnnotationKeyIntegrityHash: "test-hash", + } + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(secret, ars). + WithStatusSubresource(&actionsv1alpha1.AutoscalingRunnerSet{}, &actionsv1alpha1.EphemeralRunnerSet{}). + Build() + + reconciler := &AutoscalingRunnerSetReconciler{ + Client: fakeClient, + Scheme: scheme, + Log: logr.Discard(), + ControllerNamespace: "test-ns", + ResourceBuilder: ResourceBuilder{ + SecretResolver: secretresolver.New(fakeClient, NewFakeMultiClient()), + }, + } + + req := ctrl.Request{ + NamespacedName: types.NamespacedName{ + Namespace: "test-ns", + Name: "test-ars", + }, + } + + for b.Loop() { + b.StopTimer() + // Delete any existing EphemeralRunnerSet + ersList := &actionsv1alpha1.EphemeralRunnerSetList{} + _ = fakeClient.List(ctx, ersList, client.InNamespace("test-ns")) + for _, ers := range ersList.Items { + _ = fakeClient.Delete(ctx, &ers) + } + + // Reset AutoscalingRunnerSet + ars := NewMinimalAutoscalingRunnerSet("test-ns", "test-ars") + ars.Spec.GitHubConfigSecret = "test-secret" + ars.Labels = map[string]string{ + LabelKeyKubernetesVersion: build.Version, + } + ars.Finalizers = []string{autoscalingRunnerSetFinalizerName} + ars.Annotations = map[string]string{ + runnerScaleSetIDAnnotationKey: "1", + AnnotationKeyGitHubRunnerScaleSetName: "test-scale-set", + AnnotationKeyGitHubRunnerGroupName: "default", + AnnotationKeyIntegrityHash: "test-hash", + } + ResetBenchmarkAutoscalingRunnerSet(b, ctx, fakeClient, ars) + b.StartTimer() + + _, _ = reconciler.Reconcile(ctx, req) + } +} + +// BenchmarkActionsGithub_Reconcile_AutoscalingRunnerSet_CreateListener benchmarks the reconcile loop +// when creating an AutoscalingListener +func BenchmarkActionsGithub_Reconcile_AutoscalingRunnerSet_CreateListener(b *testing.B) { + ctx := context.Background() + scheme := runtime.NewScheme() + _ = actionsv1alpha1.AddToScheme(scheme) + _ = corev1.AddToScheme(scheme) + + // Create fake secret + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-secret", + Namespace: "test-ns", + }, + Data: map[string][]byte{ + "github_token": []byte("test-token"), + }, + } + + // Create AutoscalingRunnerSet with finalizer and scale set ID + ars := NewMinimalAutoscalingRunnerSet("test-ns", "test-ars") + ars.Spec.GitHubConfigSecret = "test-secret" + ars.Labels = map[string]string{ + LabelKeyKubernetesVersion: build.Version, + } + ars.Finalizers = []string{autoscalingRunnerSetFinalizerName} + ars.Annotations = map[string]string{ + runnerScaleSetIDAnnotationKey: "1", + AnnotationKeyGitHubRunnerScaleSetName: "test-scale-set", + AnnotationKeyGitHubRunnerGroupName: "default", + AnnotationKeyIntegrityHash: "test-hash", + } + + // Create EphemeralRunnerSet + ers := NewMinimalEphemeralRunnerSet("test-ns", "test-ars") + ers.Annotations = map[string]string{ + AnnotationKeyIntegrityHash: "test-hash", + } + ers.Spec.Replicas = 1 + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(secret, ars, ers). + WithStatusSubresource( + &actionsv1alpha1.AutoscalingRunnerSet{}, + &actionsv1alpha1.EphemeralRunnerSet{}, + &actionsv1alpha1.AutoscalingListener{}, + ). + Build() + + reconciler := &AutoscalingRunnerSetReconciler{ + Client: fakeClient, + Scheme: scheme, + Log: logr.Discard(), + ControllerNamespace: "test-ns", + DefaultRunnerScaleSetListenerImage: "ghcr.io/actions/arc:latest", + ResourceBuilder: ResourceBuilder{ + SecretResolver: secretresolver.New(fakeClient, NewFakeMultiClient()), + }, + } + + req := ctrl.Request{ + NamespacedName: types.NamespacedName{ + Namespace: "test-ns", + Name: "test-ars", + }, + } + + for b.Loop() { + b.StopTimer() + // Delete any existing AutoscalingListener + listenerList := &actionsv1alpha1.AutoscalingListenerList{} + _ = fakeClient.List(ctx, listenerList, client.InNamespace("test-ns")) + for _, listener := range listenerList.Items { + _ = fakeClient.Delete(ctx, &listener) + } + b.StartTimer() + + _, _ = reconciler.Reconcile(ctx, req) + } +} diff --git a/controllers/actions.github.com/benchmark_controller_utils_test.go b/controllers/actions.github.com/benchmark_controller_utils_test.go new file mode 100644 index 00000000..8724a3e1 --- /dev/null +++ b/controllers/actions.github.com/benchmark_controller_utils_test.go @@ -0,0 +1,221 @@ +/* +Copyright 2020 The actions-runner-controller authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package actionsgithubcom + +import ( + "context" + "testing" + + actionsv1alpha1 "github.com/actions/actions-runner-controller/apis/actions.github.com/v1alpha1" + scalefake "github.com/actions/actions-runner-controller/controllers/actions.github.com/multiclient/fake" + "github.com/actions/scaleset" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + clientgoscheme "k8s.io/client-go/kubernetes/scheme" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +const ( + benchmarkGitHubConfigURL = "https://github.com/actions/actions-runner-controller" + benchmarkGitHubConfigSecret = "test-secret" + benchmarkRunnerImage = "ghcr.io/actions/runner:latest" +) + +func NewBenchmarkScheme(b *testing.B) *runtime.Scheme { + b.Helper() + + scheme := runtime.NewScheme() + if err := clientgoscheme.AddToScheme(scheme); err != nil { + b.Fatalf("add client-go scheme: %v", err) + } + if err := actionsv1alpha1.AddToScheme(scheme); err != nil { + b.Fatalf("add actions.github.com scheme: %v", err) + } + + return scheme +} + +func NewBenchmarkRequest(namespace, name string) ctrl.Request { + return ctrl.Request{ + NamespacedName: types.NamespacedName{ + Namespace: namespace, + Name: name, + }, + } +} + +func ResetBenchmarkAutoscalingRunnerSet(b *testing.B, ctx context.Context, k8sClient client.Client, ars *actionsv1alpha1.AutoscalingRunnerSet) { + b.Helper() + + current := &actionsv1alpha1.AutoscalingRunnerSet{} + key := client.ObjectKeyFromObject(ars) + if err := k8sClient.Get(ctx, key, current); err != nil { + b.Fatalf("get autoscaling runner set %s: %v", key, err) + } + + ars.ResourceVersion = current.ResourceVersion + if err := k8sClient.Update(ctx, ars); err != nil { + b.Fatalf("reset autoscaling runner set %s: %v", key, err) + } +} + +func WarmupIteration(b *testing.B, fn func()) { + b.Helper() + b.StopTimer() + fn() + b.StartTimer() +} + +func RunBenchmarkIterations(b *testing.B, fn func()) { + b.Helper() + b.ResetTimer() + for i := 0; i < b.N; i++ { + fn() + } +} + +func NewFakeMultiClient() *scalefake.MultiClient { + runnerGroup := &scaleset.RunnerGroup{ID: 1, Name: "default"} + runnerScaleSet := &scaleset.RunnerScaleSet{ + ID: 1, + Name: "test-scale-set", + RunnerGroupID: runnerGroup.ID, + RunnerGroupName: runnerGroup.Name, + } + runner := &scaleset.RunnerReference{ID: 1, Name: "test-runner"} + + return scalefake.NewMultiClient( + scalefake.WithClient( + scalefake.NewClient( + scalefake.WithGetRunnerGroupByName(runnerGroup, nil), + scalefake.WithGetRunnerScaleSet(runnerScaleSet, nil), + scalefake.WithGetRunnerScaleSetByID(runnerScaleSet, nil), + scalefake.WithGetRunner(runner, nil), + scalefake.WithGetRunnerByName(runner, nil), + scalefake.WithGenerateJitRunnerConfig( + &scaleset.RunnerScaleSetJitRunnerConfig{ + Runner: runner, + EncodedJITConfig: "fake-jit-config", + }, + nil, + ), + ), + ), + ) +} + +func NewMinimalAutoscalingRunnerSet(namespace, name string) *actionsv1alpha1.AutoscalingRunnerSet { + minRunners := 0 + maxRunners := 1 + + return &actionsv1alpha1.AutoscalingRunnerSet{ + TypeMeta: metav1.TypeMeta{ + APIVersion: actionsv1alpha1.GroupVersion.String(), + Kind: "AutoscalingRunnerSet", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + }, + Spec: actionsv1alpha1.AutoscalingRunnerSetSpec{ + GitHubConfigUrl: benchmarkGitHubConfigURL, + GitHubConfigSecret: benchmarkGitHubConfigSecret, + RunnerGroup: "default", + MinRunners: &minRunners, + MaxRunners: &maxRunners, + Template: benchmarkRunnerTemplate(), + }, + } +} + +func NewMinimalAutoscalingListener(namespace, name string) *actionsv1alpha1.AutoscalingListener { + return &actionsv1alpha1.AutoscalingListener{ + TypeMeta: metav1.TypeMeta{ + APIVersion: actionsv1alpha1.GroupVersion.String(), + Kind: "AutoscalingListener", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + }, + Spec: actionsv1alpha1.AutoscalingListenerSpec{ + GitHubConfigURL: benchmarkGitHubConfigURL, + GitHubConfigSecret: benchmarkGitHubConfigSecret, + RunnerScaleSetID: 1, + MaxRunners: 1, + Image: benchmarkRunnerImage, + }, + } +} + +func NewMinimalEphemeralRunnerSet(namespace, name string) *actionsv1alpha1.EphemeralRunnerSet { + return &actionsv1alpha1.EphemeralRunnerSet{ + TypeMeta: metav1.TypeMeta{ + APIVersion: actionsv1alpha1.GroupVersion.String(), + Kind: "EphemeralRunnerSet", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + }, + Spec: actionsv1alpha1.EphemeralRunnerSetSpec{ + Replicas: 1, + PatchID: 1, + EphemeralRunnerSpec: actionsv1alpha1.EphemeralRunnerSpec{ + GitHubConfigURL: benchmarkGitHubConfigURL, + GitHubConfigSecret: benchmarkGitHubConfigSecret, + RunnerScaleSetID: 1, + PodTemplateSpec: benchmarkRunnerTemplate(), + }, + }, + } +} + +func NewMinimalEphemeralRunner(namespace, name string) *actionsv1alpha1.EphemeralRunner { + return &actionsv1alpha1.EphemeralRunner{ + TypeMeta: metav1.TypeMeta{ + APIVersion: actionsv1alpha1.GroupVersion.String(), + Kind: "EphemeralRunner", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + }, + Spec: actionsv1alpha1.EphemeralRunnerSpec{ + GitHubConfigURL: benchmarkGitHubConfigURL, + GitHubConfigSecret: benchmarkGitHubConfigSecret, + RunnerScaleSetID: 1, + PodTemplateSpec: benchmarkRunnerTemplate(), + }, + } +} + +func benchmarkRunnerTemplate() corev1.PodTemplateSpec { + return corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: actionsv1alpha1.EphemeralRunnerContainerName, + Image: benchmarkRunnerImage, + }, + }, + }, + } +} diff --git a/controllers/actions.github.com/ephemeralrunner_controller_bench_test.go b/controllers/actions.github.com/ephemeralrunner_controller_bench_test.go new file mode 100644 index 00000000..f05e9d6e --- /dev/null +++ b/controllers/actions.github.com/ephemeralrunner_controller_bench_test.go @@ -0,0 +1,896 @@ +/* +Copyright 2020 The actions-runner-controller authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package actionsgithubcom + +import ( + "context" + "errors" + "testing" + "time" + + actionsv1alpha1 "github.com/actions/actions-runner-controller/apis/actions.github.com/v1alpha1" + "github.com/actions/actions-runner-controller/controllers/actions.github.com/multiclient/fake" + "github.com/actions/actions-runner-controller/controllers/actions.github.com/secretresolver" + "github.com/actions/scaleset" + "github.com/go-logr/logr" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + ctrl "sigs.k8s.io/controller-runtime" + clientfake "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +// BenchmarkActionsGithub_Reconcile_EphemeralRunner_NoOp benchmarks a no-op reconcile +// scenario where an existing EphemeralRunner with finalizers requires no changes +func BenchmarkActionsGithub_Reconcile_EphemeralRunner_NoOp(b *testing.B) { + ctx := context.Background() + + // Setup: Create scheme and fake client + scheme := runtime.NewScheme() + _ = actionsv1alpha1.AddToScheme(scheme) + _ = corev1.AddToScheme(scheme) + + // Create minimal EphemeralRunner with finalizers already added + runner := NewMinimalEphemeralRunner("default", "test-runner") + runner.Finalizers = []string{ + ephemeralRunnerFinalizerName, + ephemeralRunnerActionsFinalizerName, + } + runner.Spec.GitHubConfigSecret = "config-secret" + runner.Status.RunnerID = 12345 + runner.Status.RunnerName = "test-runner" + + // Create config secret + configSecret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "config-secret", + Namespace: "default", + }, + Data: map[string][]byte{ + "github_token": []byte("test-token"), + }, + } + + // Create JIT secret for the runner (already exists, so no JIT creation needed) + jitSecret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-runner", + Namespace: "default", + }, + Data: map[string][]byte{ + "jitToken": []byte("test-jit-token"), + "runnerId": []byte("12345"), + "runnerName": []byte("test-runner"), + }, + } + + // Create pod (already exists, so no pod creation needed) + pod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-runner", + Namespace: "default", + }, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "runner", + Image: "ghcr.io/actions/runner:latest", + }, + }, + }, + Status: corev1.PodStatus{ + Phase: corev1.PodRunning, + }, + } + + k8sClient := clientfake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(runner, configSecret, jitSecret, pod). + WithStatusSubresource(&actionsv1alpha1.EphemeralRunner{}, &corev1.Pod{}). + Build() + + // Setup reconciler + multiClient := NewFakeMultiClient() + reconciler := &EphemeralRunnerReconciler{ + Client: k8sClient, + Scheme: scheme, + Log: logr.Discard(), + ResourceBuilder: ResourceBuilder{ + SecretResolver: secretresolver.New(k8sClient, multiClient), + }, + } + + req := ctrl.Request{ + NamespacedName: types.NamespacedName{ + Namespace: "default", + Name: "test-runner", + }, + } + + // Warmup iteration + WarmupIteration(b, func() { + _, _ = reconciler.Reconcile(ctx, req) + }) + + // Benchmark loop + + for b.Loop() { + _, err := reconciler.Reconcile(ctx, req) + if err != nil { + b.Fatalf("unexpected error: %v", err) + } + } +} + +// BenchmarkActionsGithub_Reconcile_EphemeralRunner_FinalizerAdd benchmarks the +// path where finalizers are added to a new EphemeralRunner +func BenchmarkActionsGithub_Reconcile_EphemeralRunner_FinalizerAdd(b *testing.B) { + ctx := context.Background() + + // Setup: Create scheme + scheme := runtime.NewScheme() + _ = actionsv1alpha1.AddToScheme(scheme) + _ = corev1.AddToScheme(scheme) + + multiClient := fake.NewMultiClient( + fake.WithClient( + fake.NewClient( + fake.WithGetRunner(nil, scaleset.RunnerNotFoundError), + fake.WithGenerateJitRunnerConfig( + &scaleset.RunnerScaleSetJitRunnerConfig{ + Runner: &scaleset.RunnerReference{ID: 1, Name: "test-runner"}, + EncodedJITConfig: "fake-jit-config", + }, + nil, + ), + ), + ), + ) + + // Warmup iteration + { + runner := NewMinimalEphemeralRunner("default", "warmup-runner") + runner.Spec.GitHubConfigSecret = "config-secret" + + configSecret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "config-secret", + Namespace: "default", + }, + Data: map[string][]byte{ + "github_token": []byte("test-token"), + }, + } + + k8sClient := clientfake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(runner, configSecret). + WithStatusSubresource(&actionsv1alpha1.EphemeralRunner{}). + Build() + + reconciler := &EphemeralRunnerReconciler{ + Client: k8sClient, + Scheme: scheme, + Log: logr.Discard(), + ResourceBuilder: ResourceBuilder{ + SecretResolver: secretresolver.New(k8sClient, multiClient), + }, + } + + req := ctrl.Request{ + NamespacedName: types.NamespacedName{ + Namespace: "default", + Name: "warmup-runner", + }, + } + + _, _ = reconciler.Reconcile(ctx, req) + } + + // Benchmark loop: Each iteration needs fresh objects since finalizers are added + + for b.Loop() { + b.StopTimer() + + runner := NewMinimalEphemeralRunner("default", "test-runner") + runner.Spec.GitHubConfigSecret = "config-secret" + + configSecret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "config-secret", + Namespace: "default", + }, + Data: map[string][]byte{ + "github_token": []byte("test-token"), + }, + } + + k8sClient := clientfake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(runner, configSecret). + WithStatusSubresource(&actionsv1alpha1.EphemeralRunner{}). + Build() + + reconciler := &EphemeralRunnerReconciler{ + Client: k8sClient, + Scheme: scheme, + Log: logr.Discard(), + ResourceBuilder: ResourceBuilder{ + SecretResolver: secretresolver.New(k8sClient, multiClient), + }, + } + + req := ctrl.Request{ + NamespacedName: types.NamespacedName{ + Namespace: "default", + Name: "test-runner", + }, + } + + b.StartTimer() + + _, err := reconciler.Reconcile(ctx, req) + if err != nil { + b.Fatalf("unexpected error: %v", err) + } + } +} + +// BenchmarkActionsGithub_Reconcile_EphemeralRunner_Deletion benchmarks the deletion +// path where finalizers are removed and resources are cleaned up +func BenchmarkActionsGithub_Reconcile_EphemeralRunner_Deletion(b *testing.B) { + ctx := context.Background() + + // Setup: Create scheme + scheme := runtime.NewScheme() + _ = actionsv1alpha1.AddToScheme(scheme) + _ = corev1.AddToScheme(scheme) + + multiClient := NewFakeMultiClient() + + // Warmup iteration + { + now := metav1.Now() + runner := NewMinimalEphemeralRunner("default", "warmup-runner") + runner.Finalizers = []string{ephemeralRunnerFinalizerName} + runner.DeletionTimestamp = &now + runner.Spec.GitHubConfigSecret = "config-secret" + + configSecret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "config-secret", + Namespace: "default", + }, + Data: map[string][]byte{ + "github_token": []byte("test-token"), + }, + } + + k8sClient := clientfake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(runner, configSecret). + WithStatusSubresource(&actionsv1alpha1.EphemeralRunner{}). + Build() + + reconciler := &EphemeralRunnerReconciler{ + Client: k8sClient, + Scheme: scheme, + Log: logr.Discard(), + ResourceBuilder: ResourceBuilder{ + SecretResolver: secretresolver.New(k8sClient, multiClient), + }, + } + + req := ctrl.Request{ + NamespacedName: types.NamespacedName{ + Namespace: "default", + Name: "warmup-runner", + }, + } + + _, _ = reconciler.Reconcile(ctx, req) + } + + // Benchmark loop: Each iteration needs fresh objects + + for b.Loop() { + b.StopTimer() + + now := metav1.Now() + runner := NewMinimalEphemeralRunner("default", "test-runner") + runner.Finalizers = []string{ephemeralRunnerFinalizerName} + runner.DeletionTimestamp = &now + runner.Spec.GitHubConfigSecret = "config-secret" + + configSecret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "config-secret", + Namespace: "default", + }, + Data: map[string][]byte{ + "github_token": []byte("test-token"), + }, + } + + k8sClient := clientfake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(runner, configSecret). + WithStatusSubresource(&actionsv1alpha1.EphemeralRunner{}). + Build() + + reconciler := &EphemeralRunnerReconciler{ + Client: k8sClient, + Scheme: scheme, + Log: logr.Discard(), + ResourceBuilder: ResourceBuilder{ + SecretResolver: secretresolver.New(k8sClient, multiClient), + }, + } + + req := ctrl.Request{ + NamespacedName: types.NamespacedName{ + Namespace: "default", + Name: "test-runner", + }, + } + + b.StartTimer() + + _, err := reconciler.Reconcile(ctx, req) + if err != nil { + b.Fatalf("unexpected error: %v", err) + } + } +} + +// BenchmarkActionsGithub_Reconcile_EphemeralRunner_DeletionWithActionsCleanup benchmarks +// the deletion path with actions finalizer requiring service cleanup +func BenchmarkActionsGithub_Reconcile_EphemeralRunner_DeletionWithActionsCleanup(b *testing.B) { + ctx := context.Background() + + // Setup: Create scheme + scheme := runtime.NewScheme() + _ = actionsv1alpha1.AddToScheme(scheme) + _ = corev1.AddToScheme(scheme) + + // Configure fake client to simulate successful cleanup + multiClient := fake.NewMultiClient( + fake.WithClient( + fake.NewClient( + fake.WithRemoveRunner(nil), // Successful cleanup + ), + ), + ) + + // Warmup iteration + { + now := metav1.Now() + runner := NewMinimalEphemeralRunner("default", "warmup-runner") + runner.Finalizers = []string{ + ephemeralRunnerFinalizerName, + ephemeralRunnerActionsFinalizerName, + } + runner.DeletionTimestamp = &now + runner.Status.RunnerID = 12345 + runner.Spec.GitHubConfigSecret = "config-secret" + + configSecret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "config-secret", + Namespace: "default", + }, + Data: map[string][]byte{ + "github_token": []byte("test-token"), + }, + } + + k8sClient := clientfake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(runner, configSecret). + WithStatusSubresource(&actionsv1alpha1.EphemeralRunner{}). + Build() + + reconciler := &EphemeralRunnerReconciler{ + Client: k8sClient, + Scheme: scheme, + Log: logr.Discard(), + ResourceBuilder: ResourceBuilder{ + SecretResolver: secretresolver.New(k8sClient, multiClient), + }, + } + + req := ctrl.Request{ + NamespacedName: types.NamespacedName{ + Namespace: "default", + Name: "warmup-runner", + }, + } + + _, _ = reconciler.Reconcile(ctx, req) + } + + // Benchmark loop + + for b.Loop() { + b.StopTimer() + + // Create EphemeralRunner marked for deletion with both finalizers + now := metav1.Now() + runner := NewMinimalEphemeralRunner("default", "test-runner") + runner.Finalizers = []string{ + ephemeralRunnerFinalizerName, + ephemeralRunnerActionsFinalizerName, + } + runner.DeletionTimestamp = &now + runner.Status.RunnerID = 12345 + runner.Spec.GitHubConfigSecret = "config-secret" + + configSecret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "config-secret", + Namespace: "default", + }, + Data: map[string][]byte{ + "github_token": []byte("test-token"), + }, + } + + k8sClient := clientfake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(runner, configSecret). + WithStatusSubresource(&actionsv1alpha1.EphemeralRunner{}). + Build() + + reconciler := &EphemeralRunnerReconciler{ + Client: k8sClient, + Scheme: scheme, + Log: logr.Discard(), + ResourceBuilder: ResourceBuilder{ + SecretResolver: secretresolver.New(k8sClient, multiClient), + }, + } + + req := ctrl.Request{ + NamespacedName: types.NamespacedName{ + Namespace: "default", + Name: "test-runner", + }, + } + + b.StartTimer() + + _, err := reconciler.Reconcile(ctx, req) + if err != nil { + b.Fatalf("unexpected error: %v", err) + } + } +} + +// BenchmarkActionsGithub_Reconcile_EphemeralRunner_RetryableError benchmarks the +// path where a retryable error occurs during reconciliation +func BenchmarkActionsGithub_Reconcile_EphemeralRunner_RetryableError(b *testing.B) { + ctx := context.Background() + + // Setup: Create scheme + scheme := runtime.NewScheme() + _ = actionsv1alpha1.AddToScheme(scheme) + _ = corev1.AddToScheme(scheme) + + // Configure fake client to return transient error + retryableErr := errors.New("transient API error") + multiClient := fake.NewMultiClient( + fake.WithClient( + fake.NewClient( + fake.WithRemoveRunner(retryableErr), + ), + ), + ) + + // Warmup iteration + { + now := metav1.Now() + runner := NewMinimalEphemeralRunner("default", "warmup-runner") + runner.Finalizers = []string{ + ephemeralRunnerFinalizerName, + ephemeralRunnerActionsFinalizerName, + } + runner.DeletionTimestamp = &now + runner.Status.RunnerID = 12345 + runner.Spec.GitHubConfigSecret = "config-secret" + + configSecret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "config-secret", + Namespace: "default", + }, + Data: map[string][]byte{ + "github_token": []byte("test-token"), + }, + } + + k8sClient := clientfake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(runner, configSecret). + WithStatusSubresource(&actionsv1alpha1.EphemeralRunner{}). + Build() + + reconciler := &EphemeralRunnerReconciler{ + Client: k8sClient, + Scheme: scheme, + Log: logr.Discard(), + ResourceBuilder: ResourceBuilder{ + SecretResolver: secretresolver.New(k8sClient, multiClient), + }, + } + + req := ctrl.Request{ + NamespacedName: types.NamespacedName{ + Namespace: "default", + Name: "warmup-runner", + }, + } + + _, _ = reconciler.Reconcile(ctx, req) + } + + // Benchmark loop + + for b.Loop() { + b.StopTimer() + + // Create EphemeralRunner that will trigger error during cleanup + now := metav1.Now() + runner := NewMinimalEphemeralRunner("default", "test-runner") + runner.Finalizers = []string{ + ephemeralRunnerFinalizerName, + ephemeralRunnerActionsFinalizerName, + } + runner.DeletionTimestamp = &now + runner.Status.RunnerID = 12345 + runner.Spec.GitHubConfigSecret = "config-secret" + + configSecret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "config-secret", + Namespace: "default", + }, + Data: map[string][]byte{ + "github_token": []byte("test-token"), + }, + } + + k8sClient := clientfake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(runner, configSecret). + WithStatusSubresource(&actionsv1alpha1.EphemeralRunner{}). + Build() + + reconciler := &EphemeralRunnerReconciler{ + Client: k8sClient, + Scheme: scheme, + Log: logr.Discard(), + ResourceBuilder: ResourceBuilder{ + SecretResolver: secretresolver.New(k8sClient, multiClient), + }, + } + + req := ctrl.Request{ + NamespacedName: types.NamespacedName{ + Namespace: "default", + Name: "test-runner", + }, + } + + b.StartTimer() + + result, err := reconciler.Reconcile(ctx, req) + + // Verify error is returned (expected behavior for retryable errors) + if err == nil { + b.Fatalf("expected error, got nil") + } + if result.RequeueAfter != 0 { + b.Fatalf("expected no requeue delay on error, got %v", result.RequeueAfter) + } + } +} + +// BenchmarkActionsGithub_Reconcile_EphemeralRunner_DonePhaseCleanup benchmarks +// the path where an EphemeralRunner in Done phase triggers resource cleanup +func BenchmarkActionsGithub_Reconcile_EphemeralRunner_DonePhaseCleanup(b *testing.B) { + ctx := context.Background() + + // Setup: Create scheme + scheme := runtime.NewScheme() + _ = actionsv1alpha1.AddToScheme(scheme) + _ = corev1.AddToScheme(scheme) + + multiClient := NewFakeMultiClient() + + // Warmup iteration + { + runner := NewMinimalEphemeralRunner("default", "warmup-runner") + runner.Finalizers = []string{ + ephemeralRunnerFinalizerName, + ephemeralRunnerActionsFinalizerName, + } + runner.Status.Phase = "Succeeded" + runner.Spec.GitHubConfigSecret = "config-secret" + + // Create a pod that will be cleaned up + pod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "warmup-runner", + Namespace: "default", + OwnerReferences: []metav1.OwnerReference{ + { + APIVersion: actionsv1alpha1.GroupVersion.String(), + Kind: "EphemeralRunner", + Name: "warmup-runner", + UID: runner.UID, + }, + }, + }, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "runner", + Image: "ghcr.io/actions/runner:latest", + }, + }, + }, + } + + configSecret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "config-secret", + Namespace: "default", + }, + Data: map[string][]byte{ + "github_token": []byte("test-token"), + }, + } + + k8sClient := clientfake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(runner, pod, configSecret). + WithStatusSubresource(&actionsv1alpha1.EphemeralRunner{}). + Build() + + reconciler := &EphemeralRunnerReconciler{ + Client: k8sClient, + Scheme: scheme, + Log: logr.Discard(), + ResourceBuilder: ResourceBuilder{ + SecretResolver: secretresolver.New(k8sClient, multiClient), + }, + } + + req := ctrl.Request{ + NamespacedName: types.NamespacedName{ + Namespace: "default", + Name: "warmup-runner", + }, + } + + _, _ = reconciler.Reconcile(ctx, req) + } + + // Benchmark loop + + for b.Loop() { + b.StopTimer() + + // Create EphemeralRunner in Done phase (Succeeded) + runner := NewMinimalEphemeralRunner("default", "test-runner") + runner.Finalizers = []string{ + ephemeralRunnerFinalizerName, + ephemeralRunnerActionsFinalizerName, + } + runner.Status.Phase = "Succeeded" + runner.Spec.GitHubConfigSecret = "config-secret" + + // Create a pod that will be cleaned up + pod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-runner", + Namespace: "default", + OwnerReferences: []metav1.OwnerReference{ + { + APIVersion: actionsv1alpha1.GroupVersion.String(), + Kind: "EphemeralRunner", + Name: "test-runner", + UID: runner.UID, + }, + }, + }, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "runner", + Image: "ghcr.io/actions/runner:latest", + }, + }, + }, + } + + configSecret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "config-secret", + Namespace: "default", + }, + Data: map[string][]byte{ + "github_token": []byte("test-token"), + }, + } + + k8sClient := clientfake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(runner, pod, configSecret). + WithStatusSubresource(&actionsv1alpha1.EphemeralRunner{}). + Build() + + reconciler := &EphemeralRunnerReconciler{ + Client: k8sClient, + Scheme: scheme, + Log: logr.Discard(), + ResourceBuilder: ResourceBuilder{ + SecretResolver: secretresolver.New(k8sClient, multiClient), + }, + } + + req := ctrl.Request{ + NamespacedName: types.NamespacedName{ + Namespace: "default", + Name: "test-runner", + }, + } + + b.StartTimer() + + _, err := reconciler.Reconcile(ctx, req) + if err != nil { + b.Fatalf("unexpected error: %v", err) + } + } +} + +// BenchmarkActionsGithub_Reconcile_EphemeralRunner_RequeueAfter benchmarks the +// path where cleanup is not finished and a requeue is requested +func BenchmarkActionsGithub_Reconcile_EphemeralRunner_RequeueAfter(b *testing.B) { + ctx := context.Background() + + // Setup: Create scheme + scheme := runtime.NewScheme() + _ = actionsv1alpha1.AddToScheme(scheme) + _ = corev1.AddToScheme(scheme) + + multiClient := fake.NewMultiClient( + fake.WithClient( + fake.NewClient( + fake.WithRemoveRunner(scaleset.JobStillRunningError), + ), + ), + ) + + // Warmup iteration + { + now := metav1.Now() + runner := NewMinimalEphemeralRunner("default", "warmup-runner") + runner.Finalizers = []string{ + ephemeralRunnerFinalizerName, + ephemeralRunnerActionsFinalizerName, + } + runner.DeletionTimestamp = &now + runner.Status.RunnerID = 12345 + runner.Spec.GitHubConfigSecret = "config-secret" + + configSecret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "config-secret", + Namespace: "default", + }, + Data: map[string][]byte{ + "github_token": []byte("test-token"), + }, + } + + k8sClient := clientfake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(runner, configSecret). + WithStatusSubresource(&actionsv1alpha1.EphemeralRunner{}). + Build() + + reconciler := &EphemeralRunnerReconciler{ + Client: k8sClient, + Scheme: scheme, + Log: logr.Discard(), + ResourceBuilder: ResourceBuilder{ + SecretResolver: secretresolver.New(k8sClient, multiClient), + }, + } + + req := ctrl.Request{ + NamespacedName: types.NamespacedName{ + Namespace: "default", + Name: "warmup-runner", + }, + } + + _, _ = reconciler.Reconcile(ctx, req) + } + + // Benchmark loop + + for b.Loop() { + b.StopTimer() + + now := metav1.Now() + runner := NewMinimalEphemeralRunner("default", "test-runner") + runner.Finalizers = []string{ + ephemeralRunnerFinalizerName, + ephemeralRunnerActionsFinalizerName, + } + runner.DeletionTimestamp = &now + runner.Status.RunnerID = 12345 + runner.Spec.GitHubConfigSecret = "config-secret" + + configSecret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "config-secret", + Namespace: "default", + }, + Data: map[string][]byte{ + "github_token": []byte("test-token"), + }, + } + + k8sClient := clientfake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(runner, configSecret). + WithStatusSubresource(&actionsv1alpha1.EphemeralRunner{}). + Build() + + reconciler := &EphemeralRunnerReconciler{ + Client: k8sClient, + Scheme: scheme, + Log: logr.Discard(), + ResourceBuilder: ResourceBuilder{ + SecretResolver: secretresolver.New(k8sClient, multiClient), + }, + } + + req := ctrl.Request{ + NamespacedName: types.NamespacedName{ + Namespace: "default", + Name: "test-runner", + }, + } + + b.StartTimer() + + result, err := reconciler.Reconcile(ctx, req) + if err != nil { + b.Fatalf("unexpected error: %v", err) + } + + if result.RequeueAfter != 30*time.Second { + b.Fatalf("expected RequeueAfter=30s, got %v", result.RequeueAfter) + } + } +} diff --git a/controllers/actions.github.com/ephemeralrunnerset_controller_bench_test.go b/controllers/actions.github.com/ephemeralrunnerset_controller_bench_test.go new file mode 100644 index 00000000..459cad62 --- /dev/null +++ b/controllers/actions.github.com/ephemeralrunnerset_controller_bench_test.go @@ -0,0 +1,418 @@ +/* +Copyright 2020 The actions-runner-controller authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package actionsgithubcom + +import ( + "context" + "fmt" + "testing" + + actionsv1alpha1 "github.com/actions/actions-runner-controller/apis/actions.github.com/v1alpha1" + "github.com/go-logr/logr" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" +) + +// BenchmarkActionsGithub_Reconcile_EphemeralRunnerSet_NoOp benchmarks the steady-state +// no-op reconciliation path where the EphemeralRunnerSet already has finalizer, +// correct spec hash, and the desired number of runners +func BenchmarkActionsGithub_Reconcile_EphemeralRunnerSet_NoOp(b *testing.B) { + testCases := []struct { + name string + replicas int + }{ + {name: "small_3_replicas", replicas: 3}, + {name: "medium_10_replicas", replicas: 10}, + {name: "large_50_replicas", replicas: 50}, + } + + for _, tc := range testCases { + b.Run(tc.name, func(b *testing.B) { + scheme := runtime.NewScheme() + _ = actionsv1alpha1.AddToScheme(scheme) + _ = corev1.AddToScheme(scheme) + + ers := NewMinimalEphemeralRunnerSet("default", "test-ers") + ers.Spec.Replicas = tc.replicas + controllerutil.AddFinalizer(ers, EphemeralRunnerSetFinalizerName) + + integrityHash := ephemeralRunnerSetIntegrityHash(ers) + if ers.Annotations == nil { + ers.Annotations = make(map[string]string) + } + ers.Annotations[AnnotationKeyIntegrityHash] = integrityHash + + ers.Status.CurrentReplicas = tc.replicas + ers.Status.Phase = actionsv1alpha1.EphemeralRunnerSetPhaseRunning + + runners := make([]client.Object, 0, tc.replicas+1) + runners = append(runners, ers) + + for i := 0; i < tc.replicas; i++ { + runner := NewMinimalEphemeralRunner("default", fmt.Sprintf("test-runner-%d", i)) + runner.Status.Phase = actionsv1alpha1.EphemeralRunnerPhaseRunning + runner.Status.RunnerID = i + 100 + runner.OwnerReferences = []metav1.OwnerReference{ + { + APIVersion: actionsv1alpha1.GroupVersion.String(), + Kind: "EphemeralRunnerSet", + Name: ers.Name, + UID: ers.UID, + Controller: func() *bool { t := true; return &t }(), + }, + } + runners = append(runners, runner) + } + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(runners...). + WithStatusSubresource(&actionsv1alpha1.EphemeralRunnerSet{}, &actionsv1alpha1.EphemeralRunner{}). + WithIndex(&actionsv1alpha1.EphemeralRunner{}, resourceOwnerKey, newGroupVersionOwnerKindIndexer("EphemeralRunnerSet")). + Build() + + reconciler := &EphemeralRunnerSetReconciler{ + Client: fakeClient, + Scheme: scheme, + Log: logr.Discard(), + } + + ctx := context.Background() + req := ctrl.Request{ + NamespacedName: types.NamespacedName{ + Namespace: "default", + Name: "test-ers", + }, + } + + WarmupIteration(b, func() { + _, _ = reconciler.Reconcile(ctx, req) + }) + + b.ResetTimer() + for b.Loop() { + _, _ = reconciler.Reconcile(ctx, req) + } + }) + } +} + +// BenchmarkActionsGithub_Reconcile_EphemeralRunnerSet_ScaleUp benchmarks the scale-up +// reconciliation path where new EphemeralRunners need to be created +func BenchmarkActionsGithub_Reconcile_EphemeralRunnerSet_ScaleUp(b *testing.B) { + testCases := []struct { + name string + current int + desired int + scaleUpBy int + }{ + {name: "small_scale_0_to_3", current: 0, desired: 3, scaleUpBy: 3}, + {name: "medium_scale_5_to_15", current: 5, desired: 15, scaleUpBy: 10}, + {name: "large_scale_10_to_60", current: 10, desired: 60, scaleUpBy: 50}, + } + + for _, tc := range testCases { + b.Run(tc.name, func(b *testing.B) { + scheme := runtime.NewScheme() + _ = actionsv1alpha1.AddToScheme(scheme) + _ = corev1.AddToScheme(scheme) + + b.ResetTimer() + for b.Loop() { + b.StopTimer() + + ers := NewMinimalEphemeralRunnerSet("default", "test-ers") + ers.Spec.Replicas = tc.desired + controllerutil.AddFinalizer(ers, EphemeralRunnerSetFinalizerName) + + integrityHash := ephemeralRunnerSetIntegrityHash(ers) + if ers.Annotations == nil { + ers.Annotations = make(map[string]string) + } + ers.Annotations[AnnotationKeyIntegrityHash] = integrityHash + + ers.Status.CurrentReplicas = tc.current + ers.Status.Phase = actionsv1alpha1.EphemeralRunnerSetPhaseRunning + + runners := make([]client.Object, 0, tc.current+1) + runners = append(runners, ers) + + for j := 0; j < tc.current; j++ { + runner := NewMinimalEphemeralRunner("default", fmt.Sprintf("test-runner-%d", j)) + runner.Status.Phase = actionsv1alpha1.EphemeralRunnerPhaseRunning + runner.Status.RunnerID = j + 100 + runner.OwnerReferences = []metav1.OwnerReference{ + { + APIVersion: actionsv1alpha1.GroupVersion.String(), + Kind: "EphemeralRunnerSet", + Name: ers.Name, + UID: ers.UID, + Controller: func() *bool { t := true; return &t }(), + }, + } + runners = append(runners, runner) + } + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(runners...). + WithStatusSubresource(&actionsv1alpha1.EphemeralRunnerSet{}, &actionsv1alpha1.EphemeralRunner{}). + WithIndex(&actionsv1alpha1.EphemeralRunner{}, resourceOwnerKey, newGroupVersionOwnerKindIndexer("EphemeralRunnerSet")). + Build() + + reconciler := &EphemeralRunnerSetReconciler{ + Client: fakeClient, + Scheme: scheme, + Log: logr.Discard(), + } + + ctx := context.Background() + req := ctrl.Request{ + NamespacedName: types.NamespacedName{ + Namespace: "default", + Name: "test-ers", + }, + } + + b.StartTimer() + _, _ = reconciler.Reconcile(ctx, req) + } + }) + } +} + +// BenchmarkActionsGithub_Reconcile_EphemeralRunnerSet_FinalizerAdd benchmarks the +// reconciliation path where the finalizer needs to be added to a new EphemeralRunnerSet +func BenchmarkActionsGithub_Reconcile_EphemeralRunnerSet_FinalizerAdd(b *testing.B) { + testCases := []struct { + name string + replicas int + }{ + {name: "small_3_replicas", replicas: 3}, + {name: "medium_10_replicas", replicas: 10}, + {name: "large_50_replicas", replicas: 50}, + } + + for _, tc := range testCases { + b.Run(tc.name, func(b *testing.B) { + scheme := runtime.NewScheme() + _ = actionsv1alpha1.AddToScheme(scheme) + _ = corev1.AddToScheme(scheme) + + b.ResetTimer() + for b.Loop() { + b.StopTimer() + + ers := NewMinimalEphemeralRunnerSet("default", "test-ers") + ers.Spec.Replicas = tc.replicas + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(ers). + WithStatusSubresource(&actionsv1alpha1.EphemeralRunnerSet{}, &actionsv1alpha1.EphemeralRunner{}). + WithIndex(&actionsv1alpha1.EphemeralRunner{}, resourceOwnerKey, newGroupVersionOwnerKindIndexer("EphemeralRunnerSet")). + Build() + + reconciler := &EphemeralRunnerSetReconciler{ + Client: fakeClient, + Scheme: scheme, + Log: logr.Discard(), + } + + ctx := context.Background() + req := ctrl.Request{ + NamespacedName: types.NamespacedName{ + Namespace: "default", + Name: "test-ers", + }, + } + + b.StartTimer() + _, _ = reconciler.Reconcile(ctx, req) + } + }) + } +} + +// BenchmarkActionsGithub_Reconcile_EphemeralRunnerSet_HashChangeDetection benchmarks the +// reconciliation path that detects a spec hash change (but before cleanup begins) +func BenchmarkActionsGithub_Reconcile_EphemeralRunnerSet_HashChangeDetection(b *testing.B) { + testCases := []struct { + name string + replicas int + }{ + {name: "small_5_replicas", replicas: 5}, + {name: "medium_20_replicas", replicas: 20}, + {name: "large_100_replicas", replicas: 100}, + } + + for _, tc := range testCases { + b.Run(tc.name, func(b *testing.B) { + scheme := runtime.NewScheme() + _ = actionsv1alpha1.AddToScheme(scheme) + _ = corev1.AddToScheme(scheme) + + b.ResetTimer() + for b.Loop() { + b.StopTimer() + + ers := NewMinimalEphemeralRunnerSet("default", "test-ers") + ers.Spec.Replicas = tc.replicas + controllerutil.AddFinalizer(ers, EphemeralRunnerSetFinalizerName) + + if ers.Annotations == nil { + ers.Annotations = make(map[string]string) + } + ers.Annotations[AnnotationKeyIntegrityHash] = "outdated-hash-12345" + + ers.Status.CurrentReplicas = tc.replicas + ers.Status.Phase = actionsv1alpha1.EphemeralRunnerSetPhaseRunning + + runners := make([]client.Object, 0, tc.replicas+1) + runners = append(runners, ers) + + for j := 0; j < tc.replicas; j++ { + runner := NewMinimalEphemeralRunner("default", fmt.Sprintf("test-runner-%d", j)) + runner.Status.Phase = actionsv1alpha1.EphemeralRunnerPhaseSucceeded + runner.Status.RunnerID = j + 100 + runner.OwnerReferences = []metav1.OwnerReference{ + { + APIVersion: actionsv1alpha1.GroupVersion.String(), + Kind: "EphemeralRunnerSet", + Name: ers.Name, + UID: ers.UID, + Controller: func() *bool { t := true; return &t }(), + }, + } + runners = append(runners, runner) + } + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(runners...). + WithStatusSubresource(&actionsv1alpha1.EphemeralRunnerSet{}, &actionsv1alpha1.EphemeralRunner{}). + WithIndex(&actionsv1alpha1.EphemeralRunner{}, resourceOwnerKey, newGroupVersionOwnerKindIndexer("EphemeralRunnerSet")). + Build() + + reconciler := &EphemeralRunnerSetReconciler{ + Client: fakeClient, + Scheme: scheme, + Log: logr.Discard(), + } + + ctx := context.Background() + req := ctrl.Request{ + NamespacedName: types.NamespacedName{ + Namespace: "default", + Name: "test-ers", + }, + } + + b.StartTimer() + _, _ = reconciler.Reconcile(ctx, req) + } + }) + } +} + +// BenchmarkActionsGithub_Reconcile_EphemeralRunnerSet_OutdatedPhaseStatusUpdate benchmarks the +// reconciliation path where the EphemeralRunnerSet is in Outdated phase with only finished runners +func BenchmarkActionsGithub_Reconcile_EphemeralRunnerSet_OutdatedPhaseStatusUpdate(b *testing.B) { + testCases := []struct { + name string + runnerCount int + }{ + {name: "small_5_runners", runnerCount: 5}, + {name: "medium_20_runners", runnerCount: 20}, + {name: "large_100_runners", runnerCount: 100}, + } + + for _, tc := range testCases { + b.Run(tc.name, func(b *testing.B) { + scheme := runtime.NewScheme() + _ = actionsv1alpha1.AddToScheme(scheme) + _ = corev1.AddToScheme(scheme) + + b.ResetTimer() + for b.Loop() { + b.StopTimer() + + ers := NewMinimalEphemeralRunnerSet("default", "test-ers") + ers.Spec.Replicas = tc.runnerCount + controllerutil.AddFinalizer(ers, EphemeralRunnerSetFinalizerName) + + integrityHash := ephemeralRunnerSetIntegrityHash(ers) + if ers.Annotations == nil { + ers.Annotations = make(map[string]string) + } + ers.Annotations[AnnotationKeyIntegrityHash] = integrityHash + + ers.Status.CurrentReplicas = tc.runnerCount + ers.Status.Phase = actionsv1alpha1.EphemeralRunnerSetPhaseOutdated + + runners := make([]client.Object, 0, tc.runnerCount+1) + runners = append(runners, ers) + + for j := 0; j < tc.runnerCount; j++ { + runner := NewMinimalEphemeralRunner("default", fmt.Sprintf("test-runner-%d", j)) + runner.Status.Phase = actionsv1alpha1.EphemeralRunnerPhaseSucceeded + runner.Status.RunnerID = j + 100 + runner.OwnerReferences = []metav1.OwnerReference{ + { + APIVersion: actionsv1alpha1.GroupVersion.String(), + Kind: "EphemeralRunnerSet", + Name: ers.Name, + UID: ers.UID, + Controller: func() *bool { t := true; return &t }(), + }, + } + runners = append(runners, runner) + } + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(runners...). + WithStatusSubresource(&actionsv1alpha1.EphemeralRunnerSet{}, &actionsv1alpha1.EphemeralRunner{}). + WithIndex(&actionsv1alpha1.EphemeralRunner{}, resourceOwnerKey, newGroupVersionOwnerKindIndexer("EphemeralRunnerSet")). + Build() + + reconciler := &EphemeralRunnerSetReconciler{ + Client: fakeClient, + Scheme: scheme, + Log: logr.Discard(), + } + + ctx := context.Background() + req := ctrl.Request{ + NamespacedName: types.NamespacedName{ + Namespace: "default", + Name: "test-ers", + }, + } + + b.StartTimer() + _, _ = reconciler.Reconcile(ctx, req) + } + }) + } +}