From eb5b79cbc6decc2c14888d6f97b8e7eca71538ac Mon Sep 17 00:00:00 2001 From: Tim Ebert Date: Thu, 30 Jan 2025 21:27:42 +0100 Subject: [PATCH 1/2] Add unit tests for `controllerring` controller --- pkg/controller/controllerring/add_test.go | 105 ++++++ .../controllerring_suite_test.go | 29 ++ pkg/controller/controllerring/reconciler.go | 26 +- .../controllerring/reconciler_test.go | 327 ++++++++++++++++++ 4 files changed, 480 insertions(+), 7 deletions(-) create mode 100644 pkg/controller/controllerring/add_test.go create mode 100644 pkg/controller/controllerring/controllerring_suite_test.go create mode 100644 pkg/controller/controllerring/reconciler_test.go diff --git a/pkg/controller/controllerring/add_test.go b/pkg/controller/controllerring/add_test.go new file mode 100644 index 00000000..a0c3522a --- /dev/null +++ b/pkg/controller/controllerring/add_test.go @@ -0,0 +1,105 @@ +/* +Copyright 2025 Tim Ebert. + +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 controllerring_test + +import ( + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + coordinationv1 "k8s.io/api/coordination/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/utils/clock/testing" + "k8s.io/utils/ptr" + "sigs.k8s.io/controller-runtime/pkg/event" + "sigs.k8s.io/controller-runtime/pkg/predicate" + + . "github.com/timebertt/kubernetes-controller-sharding/pkg/controller/controllerring" +) + +var _ = Describe("Reconciler", func() { + var r *Reconciler + + BeforeEach(func() { + r = &Reconciler{} + }) + + Describe("#LeasePredicate", func() { + var ( + p predicate.Predicate + obj, objOld *coordinationv1.Lease + + fakeClock *testing.FakePassiveClock + ) + + BeforeEach(func() { + fakeClock = testing.NewFakePassiveClock(time.Now()) + r.Clock = fakeClock + + p = r.LeasePredicate() + + obj = &coordinationv1.Lease{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foo-0", + }, + Spec: coordinationv1.LeaseSpec{ + HolderIdentity: ptr.To("foo-0"), + LeaseDurationSeconds: ptr.To[int32](10), + AcquireTime: ptr.To(metav1.NewMicroTime(fakeClock.Now().Add(-5 * time.Minute))), + RenewTime: ptr.To(metav1.NewMicroTime(fakeClock.Now().Add(-2 * time.Second))), + }, + } + metav1.SetMetaDataLabel(&obj.ObjectMeta, "alpha.sharding.timebertt.dev/controllerring", "foo") + objOld = obj.DeepCopy() + }) + + It("should ignore leases with empty label", func() { + metav1.SetMetaDataLabel(&obj.ObjectMeta, "alpha.sharding.timebertt.dev/controllerring", "") + objOld = obj.DeepCopy() + + Expect(p.Create(event.CreateEvent{Object: obj})).To(BeFalse()) + Expect(p.Update(event.UpdateEvent{ObjectOld: objOld, ObjectNew: obj})).To(BeFalse()) + Expect(p.Delete(event.DeleteEvent{Object: obj})).To(BeFalse()) + }) + + It("should react on create events", func() { + Expect(p.Create(event.CreateEvent{Object: obj})).To(BeTrue()) + }) + + It("should react on delete events", func() { + Expect(p.Delete(event.DeleteEvent{Object: obj})).To(BeTrue()) + }) + + It("should react when shard state changed to available", func() { + objOld.Spec.HolderIdentity = nil + Expect(p.Update(event.UpdateEvent{ObjectOld: objOld, ObjectNew: obj})).To(BeTrue()) + }) + + It("should react when shard state changed to unavailable", func() { + obj.Spec.HolderIdentity = nil + Expect(p.Update(event.UpdateEvent{ObjectOld: objOld, ObjectNew: obj})).To(BeTrue()) + }) + + It("should ignore when shard state hasn't changed", func() { + Expect(p.Update(event.UpdateEvent{ObjectOld: objOld, ObjectNew: obj})).To(BeFalse()) + + obj.Spec.HolderIdentity = nil + objOld.Spec.HolderIdentity = nil + Expect(p.Update(event.UpdateEvent{ObjectOld: objOld, ObjectNew: obj})).To(BeFalse()) + }) + }) +}) diff --git a/pkg/controller/controllerring/controllerring_suite_test.go b/pkg/controller/controllerring/controllerring_suite_test.go new file mode 100644 index 00000000..54edb6ae --- /dev/null +++ b/pkg/controller/controllerring/controllerring_suite_test.go @@ -0,0 +1,29 @@ +/* +Copyright 2025 Tim Ebert. + +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 controllerring_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestControllerRing(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "ControllerRing Controller Suite") +} diff --git a/pkg/controller/controllerring/reconciler.go b/pkg/controller/controllerring/reconciler.go index aced82af..0b0f6b9a 100644 --- a/pkg/controller/controllerring/reconciler.go +++ b/pkg/controller/controllerring/reconciler.go @@ -94,7 +94,7 @@ func (r *Reconciler) Reconcile(ctx context.Context, req reconcile.Request) (reco } func (r *Reconciler) updateStatusSuccess(ctx context.Context, controllerRing, before *shardingv1alpha1.ControllerRing) error { - if err := r.optionallyUpdateStatus(ctx, controllerRing, before, func(ready *metav1.Condition) { + if err := r.OptionallyUpdateStatus(ctx, controllerRing, before, func(ready *metav1.Condition) { ready.Status = metav1.ConditionTrue ready.Reason = "ReconciliationSucceeded" ready.Message = "ControllerRing was successfully reconciled" @@ -109,7 +109,7 @@ func (r *Reconciler) updateStatusError(ctx context.Context, log logr.Logger, rec r.Recorder.Event(controllerRing, corev1.EventTypeWarning, "ReconciliationFailed", message) - if err := r.optionallyUpdateStatus(ctx, controllerRing, before, func(ready *metav1.Condition) { + if err := r.OptionallyUpdateStatus(ctx, controllerRing, before, func(ready *metav1.Condition) { ready.Status = metav1.ConditionFalse ready.Reason = "ReconciliationFailed" ready.Message = message @@ -122,7 +122,7 @@ func (r *Reconciler) updateStatusError(ctx context.Context, log logr.Logger, rec return reconcileError } -func (r *Reconciler) optionallyUpdateStatus(ctx context.Context, controllerRing, before *shardingv1alpha1.ControllerRing, mutate func(ready *metav1.Condition)) error { +func (r *Reconciler) OptionallyUpdateStatus(ctx context.Context, controllerRing, before *shardingv1alpha1.ControllerRing, mutate func(ready *metav1.Condition)) error { // always update status with the latest observed generation, no matter if reconciliation succeeded or not controllerRing.Status.ObservedGeneration = controllerRing.Generation readyCondition := metav1.Condition{ @@ -141,6 +141,15 @@ func (r *Reconciler) optionallyUpdateStatus(ctx context.Context, controllerRing, } func (r *Reconciler) reconcileWebhooks(ctx context.Context, controllerRing *shardingv1alpha1.ControllerRing) error { + webhookConfig, err := r.WebhookConfigForControllerRing(controllerRing) + if err != nil { + return err + } + + return r.Client.Patch(ctx, webhookConfig, client.Apply) +} + +func (r *Reconciler) WebhookConfigForControllerRing(controllerRing *shardingv1alpha1.ControllerRing) (*admissionregistrationv1.MutatingWebhookConfiguration, error) { webhookConfig := &admissionregistrationv1.MutatingWebhookConfiguration{ TypeMeta: metav1.TypeMeta{ APIVersion: admissionregistrationv1.SchemeGroupVersion.String(), @@ -154,11 +163,16 @@ func (r *Reconciler) reconcileWebhooks(ctx context.Context, controllerRing *shar }, Annotations: maps.Clone(r.Config.Webhook.Config.Annotations), }, + Webhooks: []admissionregistrationv1.MutatingWebhook{r.WebhookForControllerRing(controllerRing)}, } if err := controllerutil.SetControllerReference(controllerRing, webhookConfig, r.Client.Scheme()); err != nil { - return fmt.Errorf("error setting controller reference: %w", err) + return nil, fmt.Errorf("error setting controller reference: %w", err) } + return webhookConfig, nil +} + +func (r *Reconciler) WebhookForControllerRing(controllerRing *shardingv1alpha1.ControllerRing) admissionregistrationv1.MutatingWebhook { webhook := admissionregistrationv1.MutatingWebhook{ Name: "sharder.sharding.timebertt.dev", ClientConfig: *r.Config.Webhook.Config.ClientConfig.DeepCopy(), @@ -207,9 +221,7 @@ func (r *Reconciler) reconcileWebhooks(ctx context.Context, controllerRing *shar } } - webhookConfig.Webhooks = []admissionregistrationv1.MutatingWebhook{webhook} - - return r.Client.Patch(ctx, webhookConfig, client.Apply) + return webhook } // RuleForResource returns the sharder's webhook rule for the given resource. diff --git a/pkg/controller/controllerring/reconciler_test.go b/pkg/controller/controllerring/reconciler_test.go new file mode 100644 index 00000000..3a7e311b --- /dev/null +++ b/pkg/controller/controllerring/reconciler_test.go @@ -0,0 +1,327 @@ +/* +Copyright 2025 Tim Ebert. + +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 controllerring_test + +import ( + "context" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + . "github.com/onsi/gomega/gstruct" + admissionregistrationv1 "k8s.io/api/admissionregistration/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/utils/ptr" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + configv1alpha1 "github.com/timebertt/kubernetes-controller-sharding/pkg/apis/config/v1alpha1" + shardingv1alpha1 "github.com/timebertt/kubernetes-controller-sharding/pkg/apis/sharding/v1alpha1" + . "github.com/timebertt/kubernetes-controller-sharding/pkg/controller/controllerring" +) + +var _ = Describe("Reconciler", func() { + var ( + ctx context.Context + fakeClient client.Client + r *Reconciler + + ring *shardingv1alpha1.ControllerRing + config *configv1alpha1.SharderConfig + ) + + BeforeEach(func() { + ctx = context.Background() + + scheme := runtime.NewScheme() + Expect(shardingv1alpha1.AddToScheme(scheme)).To(Succeed()) + Expect(configv1alpha1.AddToScheme(scheme)).To(Succeed()) + + ring = &shardingv1alpha1.ControllerRing{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foo", + Generation: 1, + }, + Status: shardingv1alpha1.ControllerRingStatus{ + ObservedGeneration: 1, + Shards: 0, + AvailableShards: 0, + Conditions: []metav1.Condition{{ + Type: "Ready", + Status: metav1.ConditionTrue, + ObservedGeneration: 1, + }}, + }, + } + + config = &configv1alpha1.SharderConfig{} + scheme.Default(config) + + fakeClient = fake.NewClientBuilder(). + WithScheme(scheme). + WithRuntimeObjects(ring). + WithStatusSubresource(&shardingv1alpha1.ControllerRing{}). + Build() + + r = &Reconciler{ + Client: fakeClient, + Config: config, + } + }) + + Describe("#OptionallyUpdateStatus", func() { + It("should update status if observed generation is outdated", func() { + ring.Generation++ + Expect(fakeClient.Update(ctx, ring)).To(Succeed()) + + Expect(r.OptionallyUpdateStatus(ctx, ring, ring.DeepCopy(), func(ready *metav1.Condition) {})).Should(Succeed()) + + Expect(fakeClient.Get(ctx, client.ObjectKeyFromObject(ring), ring)).Should(Succeed()) + Expect(ring.Status.ObservedGeneration).Should(Equal(ring.Generation)) + Expect(ring.Status.Conditions[0].ObservedGeneration).Should(Equal(ring.Generation)) + }) + + It("should update status if condition mutated", func() { + Expect(r.OptionallyUpdateStatus(ctx, ring, ring.DeepCopy(), func(ready *metav1.Condition) { + ready.Status = metav1.ConditionFalse + })).Should(Succeed()) + + Expect(fakeClient.Get(ctx, client.ObjectKeyFromObject(ring), ring)).Should(Succeed()) + Expect(ring.Status.Conditions).Should(ConsistOf(MatchFields(IgnoreExtras, Fields{ + "Type": Equal("Ready"), + "Status": Equal(metav1.ConditionFalse), + "ObservedGeneration": Equal(ring.Generation), + }))) + }) + + It("should update status if mutated outside the function", func() { + before := ring.DeepCopy() + ring.Status.AvailableShards = 1 + + Expect(r.OptionallyUpdateStatus(ctx, ring, before, func(ready *metav1.Condition) {})).Should(Succeed()) + + Expect(fakeClient.Get(ctx, client.ObjectKeyFromObject(ring), ring)).Should(Succeed()) + Expect(ring.Status.AvailableShards).Should(BeEquivalentTo(1)) + }) + + It("should skip updating status if nothing changed", func() { + Expect(fakeClient.Get(ctx, client.ObjectKeyFromObject(ring), ring)).Should(Succeed()) + resourceVersion := ring.ResourceVersion + + Expect(r.OptionallyUpdateStatus(ctx, ring, ring.DeepCopy(), func(ready *metav1.Condition) { + *ready = ring.Status.Conditions[0] + })).Should(Succeed()) + + Expect(fakeClient.Get(ctx, client.ObjectKeyFromObject(ring), ring)).Should(Succeed()) + Expect(ring.ResourceVersion).Should(Equal(resourceVersion)) + }) + }) + + Describe("#WebhookConfigForControllerRing", func() { + It("should have the correct metadata", func() { + Expect(r.WebhookConfigForControllerRing(ring)).To(PointTo(MatchFields(IgnoreExtras, Fields{ + "ObjectMeta": MatchFields(IgnoreExtras, Fields{ + "Name": Equal("controllerring-" + ring.Name), + "Labels": Equal(map[string]string{ + "app.kubernetes.io/name": "controller-sharding", + "alpha.sharding.timebertt.dev/controllerring": ring.Name, + }), + }), + }))) + }) + + It("should copy the config's annotations", func() { + config.Webhook.Config.Annotations = map[string]string{ + "my": "annotation", + } + + Expect(r.WebhookConfigForControllerRing(ring)).To(PointTo( + HaveField("ObjectMeta.Annotations", Equal(map[string]string{ + "my": "annotation", + })), + )) + }) + + It("should set the controller reference", func() { + ring.UID = "123456" + + Expect(r.WebhookConfigForControllerRing(ring)).To(PointTo( + HaveField("ObjectMeta.OwnerReferences", ConsistOf(metav1.OwnerReference{ + APIVersion: "sharding.timebertt.dev/v1alpha1", + Kind: "ControllerRing", + Name: ring.Name, + UID: ring.UID, + Controller: ptr.To(true), + BlockOwnerDeletion: ptr.To(true), + })), + )) + }) + + It("should have a single webhook", func() { + Expect(r.WebhookConfigForControllerRing(ring)).To(HaveField("Webhooks", HaveLen(1))) + }) + }) + + Describe("#WebhookForControllerRing", func() { + It("should have the correct settings", func() { + Expect(r.WebhookForControllerRing(ring)).To(MatchFields(IgnoreExtras, Fields{ + "Name": Equal("sharder.sharding.timebertt.dev"), + "SideEffects": PointTo(Equal(admissionregistrationv1.SideEffectClassNone)), + "AdmissionReviewVersions": ConsistOf("v1"), + })) + }) + + It("should have non-problematic failure settings", func() { + Expect(r.WebhookForControllerRing(ring)).To(MatchFields(IgnoreExtras, Fields{ + "FailurePolicy": PointTo(Equal(admissionregistrationv1.Ignore)), + "TimeoutSeconds": PointTo(BeEquivalentTo(5)), + })) + }) + + Context("client config", func() { + It("should use the config's default client config and add the path", func() { + Expect(r.WebhookForControllerRing(ring).ClientConfig).To(Equal(admissionregistrationv1.WebhookClientConfig{ + Service: &admissionregistrationv1.ServiceReference{ + Namespace: "sharding-system", + Name: "sharder", + Path: ptr.To("/webhooks/sharder/controllerring/foo"), + }, + })) + }) + + It("should use the service client config and add the path", func() { + config.Webhook.Config.ClientConfig = &admissionregistrationv1.WebhookClientConfig{ + Service: &admissionregistrationv1.ServiceReference{ + Namespace: "default", + Name: "webhook-service", + Port: ptr.To[int32](8080), + }, + } + clientConfig := config.Webhook.Config.ClientConfig.DeepCopy() + + Expect(r.WebhookForControllerRing(ring).ClientConfig).To(Equal(admissionregistrationv1.WebhookClientConfig{ + Service: &admissionregistrationv1.ServiceReference{ + Namespace: clientConfig.Service.Namespace, + Name: clientConfig.Service.Name, + Port: clientConfig.Service.Port, + Path: ptr.To("/webhooks/sharder/controllerring/foo"), + }, + })) + }) + + It("should use the URL client config and add the path", func() { + config.Webhook.Config.ClientConfig = &admissionregistrationv1.WebhookClientConfig{ + URL: ptr.To("https://example.com/webhook"), + } + + Expect(r.WebhookForControllerRing(ring).ClientConfig).To(Equal(admissionregistrationv1.WebhookClientConfig{ + URL: ptr.To("https://example.com/webhook/webhooks/sharder/controllerring/foo"), + })) + }) + + It("should use the URL client config with a trailing slash and add the path", func() { + config.Webhook.Config.ClientConfig = &admissionregistrationv1.WebhookClientConfig{ + URL: ptr.To("https://example.com/"), + } + + Expect(r.WebhookForControllerRing(ring).ClientConfig).To(Equal(admissionregistrationv1.WebhookClientConfig{ + URL: ptr.To("https://example.com/webhooks/sharder/controllerring/foo"), + })) + }) + }) + + Context("namespace selector", func() { + It("should use the config's default namespace selector", func() { + Expect(r.WebhookForControllerRing(ring).NamespaceSelector).To(Equal(&metav1.LabelSelector{ + MatchExpressions: []metav1.LabelSelectorRequirement{{ + Key: corev1.LabelMetadataName, + Operator: metav1.LabelSelectorOpNotIn, + Values: []string{"kube-system", "sharding-system"}, + }}, + })) + }) + + It("should use the config's namespace selector", func() { + config.Webhook.Config.NamespaceSelector = &metav1.LabelSelector{ + MatchLabels: map[string]string{"my": "label"}, + } + namespaceSelector := config.Webhook.Config.NamespaceSelector.DeepCopy() + + Expect(r.WebhookForControllerRing(ring).NamespaceSelector).To(Equal(namespaceSelector)) + }) + + It("should use the ControllerRing's namespace selector", func() { + ring.Spec.NamespaceSelector = &metav1.LabelSelector{ + MatchLabels: map[string]string{"my": "label"}, + } + namespaceSelector := ring.Spec.NamespaceSelector.DeepCopy() + + Expect(r.WebhookForControllerRing(ring).NamespaceSelector).To(Equal(namespaceSelector)) + }) + }) + + It("should only select unassigned objects", func() { + selector, err := metav1.LabelSelectorAsSelector(r.WebhookForControllerRing(ring).ObjectSelector) + Expect(err).NotTo(HaveOccurred()) + + Expect(selector.Matches(labels.Set{})).To(BeTrue()) + Expect(selector.Matches(labels.Set{ring.LabelShard(): "shard-1"})).To(BeFalse()) + }) + + It("should add rules for all resources", func() { + ring.Spec.Resources = []shardingv1alpha1.RingResource{ + { + GroupResource: metav1.GroupResource{Group: "", Resource: "configmaps"}, + }, + { + GroupResource: metav1.GroupResource{Group: "apps", Resource: "deployments"}, + ControlledResources: []metav1.GroupResource{{Group: "apps", Resource: "replicasets"}}, + }, + } + + Expect(r.WebhookForControllerRing(ring).Rules).To(ConsistOf( + RuleForResource(ring.Spec.Resources[0].GroupResource), + RuleForResource(ring.Spec.Resources[1].GroupResource), + RuleForResource(ring.Spec.Resources[1].ControlledResources[0]), + )) + }) + }) + + Describe("#RuleForResource", func() { + var gr metav1.GroupResource + + BeforeEach(func() { + gr.Group = "apps" + gr.Resource = "deployments" + }) + + It("should act on Create and Update", func() { + Expect(RuleForResource(gr).Operations).To(ConsistOf(admissionregistrationv1.Create, admissionregistrationv1.Update)) + }) + + It("should generate a matching rule", func() { + Expect(RuleForResource(gr).Rule).To(Equal(admissionregistrationv1.Rule{ + APIGroups: []string{"apps"}, + APIVersions: []string{"*"}, + Resources: []string{"deployments"}, + Scope: ptr.To(admissionregistrationv1.AllScopes), + })) + }) + }) +}) From 56685ca8f562b31c0074dd1ee1122b4554b7c0c6 Mon Sep 17 00:00:00 2001 From: Tim Ebert Date: Tue, 4 Feb 2025 22:15:33 +0100 Subject: [PATCH 2/2] Add unit tests for `shardlease` controller --- pkg/controller/shardlease/add_test.go | 120 ++++++++++++++++++ .../shardlease/shardlease_suite_test.go | 29 +++++ 2 files changed, 149 insertions(+) create mode 100644 pkg/controller/shardlease/add_test.go create mode 100644 pkg/controller/shardlease/shardlease_suite_test.go diff --git a/pkg/controller/shardlease/add_test.go b/pkg/controller/shardlease/add_test.go new file mode 100644 index 00000000..ab360120 --- /dev/null +++ b/pkg/controller/shardlease/add_test.go @@ -0,0 +1,120 @@ +/* +Copyright 2025 Tim Ebert. + +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 shardlease_test + +import ( + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + coordinationv1 "k8s.io/api/coordination/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/utils/clock/testing" + "k8s.io/utils/ptr" + "sigs.k8s.io/controller-runtime/pkg/event" + "sigs.k8s.io/controller-runtime/pkg/predicate" + + . "github.com/timebertt/kubernetes-controller-sharding/pkg/controller/shardlease" + "github.com/timebertt/kubernetes-controller-sharding/pkg/sharding/leases" +) + +var _ = Describe("Reconciler", func() { + var r *Reconciler + + BeforeEach(func() { + r = &Reconciler{} + }) + + Describe("#LeasePredicate", func() { + var ( + p predicate.Predicate + obj, objOld *coordinationv1.Lease + + fakeClock *testing.FakePassiveClock + ) + + BeforeEach(func() { + fakeClock = testing.NewFakePassiveClock(time.Now()) + r.Clock = fakeClock + + p = r.LeasePredicate() + + obj = &coordinationv1.Lease{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foo-0", + }, + Spec: coordinationv1.LeaseSpec{ + HolderIdentity: ptr.To("foo-0"), + LeaseDurationSeconds: ptr.To[int32](10), + AcquireTime: ptr.To(metav1.NewMicroTime(fakeClock.Now().Add(-5 * time.Minute))), + RenewTime: ptr.To(metav1.NewMicroTime(fakeClock.Now().Add(-2 * time.Second))), + }, + } + metav1.SetMetaDataLabel(&obj.ObjectMeta, "alpha.sharding.timebertt.dev/controllerring", "foo") + objOld = obj.DeepCopy() + }) + + It("should ignore leases with empty label", func() { + metav1.SetMetaDataLabel(&obj.ObjectMeta, "alpha.sharding.timebertt.dev/controllerring", "") + objOld = obj.DeepCopy() + + Expect(p.Create(event.CreateEvent{Object: obj})).To(BeFalse()) + Expect(p.Update(event.UpdateEvent{ObjectOld: objOld, ObjectNew: obj})).To(BeFalse()) + Expect(p.Delete(event.DeleteEvent{Object: obj})).To(BeFalse()) + }) + + It("should react on create events for unavailable leases", func() { + obj.Spec.HolderIdentity = nil + Expect(p.Create(event.CreateEvent{Object: obj})).To(BeTrue()) + }) + + It("should react on create events for available leases", func() { + Expect(p.Create(event.CreateEvent{Object: obj})).To(BeTrue()) + }) + + It("should react when shard state changed to available", func() { + objOld.Spec.HolderIdentity = nil + Expect(p.Update(event.UpdateEvent{ObjectOld: objOld, ObjectNew: obj})).To(BeTrue()) + }) + + It("should react when shard state changed to unavailable", func() { + obj.Spec.HolderIdentity = nil + Expect(p.Update(event.UpdateEvent{ObjectOld: objOld, ObjectNew: obj})).To(BeTrue()) + }) + + It("should react when shard state changed while still being available", func() { + obj.Spec.RenewTime = ptr.To(metav1.NewMicroTime(fakeClock.Now().Add(-time.Duration(*obj.Spec.LeaseDurationSeconds+1) * time.Second))) + + Expect(leases.ToState(objOld, fakeClock.Now())).To(Equal(leases.Ready)) + Expect(leases.ToState(obj, fakeClock.Now())).To(Equal(leases.Expired)) + + Expect(p.Update(event.UpdateEvent{ObjectOld: objOld, ObjectNew: obj})).To(BeTrue()) + }) + + It("should ignore when shard state hasn't changed", func() { + Expect(p.Update(event.UpdateEvent{ObjectOld: objOld, ObjectNew: obj})).To(BeFalse()) + + obj.Spec.HolderIdentity = nil + objOld.Spec.HolderIdentity = nil + Expect(p.Update(event.UpdateEvent{ObjectOld: objOld, ObjectNew: obj})).To(BeFalse()) + }) + + It("should ignore delete events", func() { + Expect(p.Delete(event.DeleteEvent{Object: obj})).To(BeFalse()) + }) + }) +}) diff --git a/pkg/controller/shardlease/shardlease_suite_test.go b/pkg/controller/shardlease/shardlease_suite_test.go new file mode 100644 index 00000000..53de62b3 --- /dev/null +++ b/pkg/controller/shardlease/shardlease_suite_test.go @@ -0,0 +1,29 @@ +/* +Copyright 2025 Tim Ebert. + +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 shardlease_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestShardLease(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Shard Lease Controller Suite") +}