From b523eb6b45727a20b6ef0456b466cd8f354a61e3 Mon Sep 17 00:00:00 2001 From: Vishnu Kothakapu Date: Wed, 1 Jul 2026 17:34:08 +0530 Subject: [PATCH] fix(controller): handle long rule names in bootstrap annotation keys --- internal/controller/helper.go | 40 ++++++ internal/controller/helper_unit_test.go | 56 ++++++++ internal/controller/node_controller.go | 37 +++-- .../node_controller_reproduction_test.go | 134 ++++++++++++++++++ internal/controller/node_controller_test.go | 75 +++++++++- .../nodereadinessrule_controller.go | 6 +- .../nodereadinessrule_controller_test.go | 32 +++-- 7 files changed, 343 insertions(+), 37 deletions(-) create mode 100644 internal/controller/helper_unit_test.go create mode 100644 internal/controller/node_controller_reproduction_test.go diff --git a/internal/controller/helper.go b/internal/controller/helper.go index 3dfb12c..e8617e2 100644 --- a/internal/controller/helper.go +++ b/internal/controller/helper.go @@ -17,9 +17,49 @@ limitations under the License. package controller import ( + "encoding/json" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/types" +) + +const ( + // bootstrapAnnotationPrefix is the common prefix for all bootstrap completion + // annotations on a Node. The suffix is the rule's metadata.uid (RFC 4122 UUID, + // ~36 chars), which is immutable for the object's lifetime and globally unique. + // + // Full key format: readiness.k8s.io/bootstrap-completed- + // Value format: {"rule-name":""} (for human readability) + bootstrapAnnotationPrefix = "readiness.k8s.io/bootstrap-completed-" ) +// bootstrapAnnotationKey returns the annotation key for a rule's bootstrap +// completion state, using the rule's UID as the suffix. +func bootstrapAnnotationKey(uid types.UID) string { + return bootstrapAnnotationPrefix + string(uid) +} + +// bootstrapAnnotationValue returns the JSON-encoded value to store in the +// bootstrap annotation. It includes the rule name for human readability. +func bootstrapAnnotationValue(ruleName string) string { + v := struct { + RuleName string `json:"rule-name"` + }{RuleName: ruleName} + b, err := json.Marshal(v) + if err != nil { + return `{"rule-name":""}` // should never happen + } + return string(b) +} + + + +// legacyBootstrapAnnotationKey returns the old-format annotation key used +// before the UID migration: readiness.k8s.io/bootstrap-completed-. +func legacyBootstrapAnnotationKey(ruleName string) string { + return bootstrapAnnotationPrefix + ruleName +} + // conditionsEqual checks if two condition slices are equal. func conditionsEqual(a, b []corev1.NodeCondition) bool { if len(a) != len(b) { diff --git a/internal/controller/helper_unit_test.go b/internal/controller/helper_unit_test.go new file mode 100644 index 0000000..b42a7ec --- /dev/null +++ b/internal/controller/helper_unit_test.go @@ -0,0 +1,56 @@ +/* +Copyright The Kubernetes 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 controller + +import ( + "testing" + + . "github.com/onsi/gomega" + "k8s.io/apimachinery/pkg/types" +) + +func TestBootstrapAnnotationKey(t *testing.T) { + g := NewWithT(t) + + uid := types.UID("550e8400-e29b-41d4-a716-446655440000") + key := bootstrapAnnotationKey(uid) + g.Expect(key).To(Equal("readiness.k8s.io/bootstrap-completed-550e8400-e29b-41d4-a716-446655440000")) +} + +func TestBootstrapAnnotationValue(t *testing.T) { + g := NewWithT(t) + + t.Run("encodes rule name as JSON", func(t *testing.T) { + val := bootstrapAnnotationValue("my-rule") + g.Expect(val).To(Equal(`{"rule-name":"my-rule"}`)) + }) + + t.Run("handles long rule names", func(t *testing.T) { + longName := "my-very-long-rule-name-that-exceeds-the-63-character-annotation-key-limit-strictly" + val := bootstrapAnnotationValue(longName) + g.Expect(val).To(ContainSubstring(longName)) + }) +} + +func TestLegacyBootstrapAnnotationKey(t *testing.T) { + g := NewWithT(t) + + key := legacyBootstrapAnnotationKey("my-rule") + g.Expect(key).To(Equal("readiness.k8s.io/bootstrap-completed-my-rule")) +} + + diff --git a/internal/controller/node_controller.go b/internal/controller/node_controller.go index 8119ac9..7a9d8ab 100644 --- a/internal/controller/node_controller.go +++ b/internal/controller/node_controller.go @@ -24,6 +24,7 @@ import ( corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" "k8s.io/client-go/util/retry" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/builder" @@ -129,7 +130,7 @@ func (r *RuleReadinessController) processNodeAgainstAllRules(ctx context.Context } // Skip if bootstrap-only and already completed - if r.isBootstrapCompleted(ctx, node.Name, rule.Name) && rule.Spec.EnforcementMode == readinessv1alpha1.EnforcementModeBootstrapOnly { + if r.isBootstrapCompleted(ctx, node.Name, rule.Name, rule.GetUID()) && rule.Spec.EnforcementMode == readinessv1alpha1.EnforcementModeBootstrapOnly { log.Info("Skipping bootstrap-only rule - already completed", "node", node.Name, "rule", rule.Name) continue @@ -341,24 +342,20 @@ func (r *RuleReadinessController) removeTaintBySpec(ctx context.Context, node *c }) } -// Bootstrap completion tracking. -func (r *RuleReadinessController) isBootstrapCompleted(ctx context.Context, nodeName, ruleName string) bool { - // Check node annotation +func (r *RuleReadinessController) isBootstrapCompleted(ctx context.Context, nodeName string, ruleName string, ruleUID types.UID) bool { node := &corev1.Node{} if err := r.Get(ctx, client.ObjectKey{Name: nodeName}, node); err != nil { return false } - - annotationKey := fmt.Sprintf("readiness.k8s.io/bootstrap-completed-%s", ruleName) - _, exists := node.Annotations[annotationKey] - return exists + _, existsNew := node.Annotations[bootstrapAnnotationKey(ruleUID)] + _, existsLegacy := node.Annotations[legacyBootstrapAnnotationKey(ruleName)] + return existsNew || existsLegacy } -func (r *RuleReadinessController) markBootstrapCompleted(ctx context.Context, nodeName, ruleName string) { +func (r *RuleReadinessController) markBootstrapCompleted(ctx context.Context, nodeName, ruleName string, ruleUID types.UID) { log := ctrl.LoggerFrom(ctx) - - annotationKey := fmt.Sprintf("readiness.k8s.io/bootstrap-completed-%s", ruleName) marked := false + annotationKey := bootstrapAnnotationKey(ruleUID) // retry to handle conflict with concurrent node updates err := retry.RetryOnConflict(retry.DefaultRetry, func() error { @@ -367,21 +364,19 @@ func (r *RuleReadinessController) markBootstrapCompleted(ctx context.Context, no return err } - // Check if already marked to avoid unnecessary updates - if node.Annotations != nil { - if _, exists := node.Annotations[annotationKey]; exists { - return nil - } + // Check if already marked to avoid unnecessary updates. + if _, exists := node.Annotations[annotationKey]; exists { + return nil } patch := client.MergeFrom(node.DeepCopy()) - // Initialize annotations if nil + // Initialize annotations map if nil. if node.Annotations == nil { node.Annotations = make(map[string]string) } - node.Annotations[annotationKey] = "true" + node.Annotations[annotationKey] = bootstrapAnnotationValue(ruleName) if err := r.Patch(ctx, node, patch); err != nil { return err } @@ -392,12 +387,12 @@ func (r *RuleReadinessController) markBootstrapCompleted(ctx context.Context, no switch { case err != nil: - log.Error(err, "Failed to mark bootstrap completed", "node", nodeName, "rule", ruleName) + log.Error(err, "Failed to mark bootstrap completed", "node", nodeName, "rule", ruleName, "uid", ruleUID) case marked: - log.Info("Marked bootstrap completed", "node", nodeName, "rule", ruleName) + log.Info("Marked bootstrap completed", "node", nodeName, "rule", ruleName, "uid", ruleUID) metrics.BootstrapCompleted.WithLabelValues(ruleName).Inc() default: - log.V(4).Info("Bootstrap already completed", "node", nodeName, "rule", ruleName) + log.V(4).Info("Bootstrap already completed", "node", nodeName, "rule", ruleName, "uid", ruleUID) } } diff --git a/internal/controller/node_controller_reproduction_test.go b/internal/controller/node_controller_reproduction_test.go new file mode 100644 index 0000000..8acf050 --- /dev/null +++ b/internal/controller/node_controller_reproduction_test.go @@ -0,0 +1,134 @@ +/* +Copyright The Kubernetes 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 controller + +import ( + "context" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/kubernetes/fake" + "k8s.io/client-go/tools/record" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + nodereadinessiov1alpha1 "sigs.k8s.io/node-readiness-controller/api/v1alpha1" +) + +var _ = Describe("Node Controller Reproduction", func() { + Context("when reconciling a node with a very long rule name", func() { + var ( + ctx context.Context + readinessController *RuleReadinessController + nodeReconciler *NodeReconciler + fakeClientset *fake.Clientset + node *corev1.Node + rule *nodereadinessiov1alpha1.NodeReadinessRule + longRuleName = "my-very-long-rule-name-that-exceeds-the-annotation-key-limit-strictly" + ) + + BeforeEach(func() { + ctx = context.Background() + + fakeClientset = fake.NewSimpleClientset() + readinessController = &RuleReadinessController{ + Client: k8sClient, + Scheme: k8sClient.Scheme(), + clientset: fakeClientset, + ruleCache: make(map[string]*nodereadinessiov1alpha1.NodeReadinessRule), + EventRecorder: record.NewFakeRecorder(10), + } + + nodeReconciler = &NodeReconciler{ + Client: k8sClient, + Scheme: k8sClient.Scheme(), + Controller: readinessController, + } + + node = &corev1.Node{ + ObjectMeta: metav1.ObjectMeta{ + Name: "repro-node", + Labels: map[string]string{"env": "repro"}, + }, + Status: corev1.NodeStatus{ + Conditions: []corev1.NodeCondition{ + {Type: "ReproCondition", Status: corev1.ConditionTrue}, + }, + }, + } + + rule = &nodereadinessiov1alpha1.NodeReadinessRule{ + ObjectMeta: metav1.ObjectMeta{ + Name: longRuleName, + }, + Spec: nodereadinessiov1alpha1.NodeReadinessRuleSpec{ + Conditions: []nodereadinessiov1alpha1.ConditionRequirement{ + {Type: "ReproCondition", RequiredStatus: corev1.ConditionTrue}, + }, + Taint: corev1.Taint{ + Key: "readiness.k8s.io/repro-taint", + Effect: corev1.TaintEffectNoSchedule, + }, + NodeSelector: metav1.LabelSelector{ + MatchLabels: map[string]string{"env": "repro"}, + }, + EnforcementMode: nodereadinessiov1alpha1.EnforcementModeBootstrapOnly, + }, + } + }) + + JustBeforeEach(func() { + Expect(k8sClient.Create(ctx, node)).To(Succeed()) + Expect(k8sClient.Create(ctx, rule)).To(Succeed()) + readinessController.updateRuleCache(ctx, rule) + }) + + AfterEach(func() { + _ = k8sClient.Delete(ctx, node) + updatedRule := &nodereadinessiov1alpha1.NodeReadinessRule{} + if err := k8sClient.Get(ctx, types.NamespacedName{Name: longRuleName}, updatedRule); err == nil { + updatedRule.Finalizers = nil + _ = k8sClient.Update(ctx, updatedRule) + _ = k8sClient.Delete(ctx, updatedRule) + } + readinessController.removeRuleFromCache(ctx, longRuleName) + }) + + It("should successfully mark bootstrap completed using the UID-based annotation key for long rule names", func() { + // Trigger reconciliation + _, err := nodeReconciler.Reconcile(ctx, reconcile.Request{NamespacedName: types.NamespacedName{Name: "repro-node"}}) + Expect(err).NotTo(HaveOccurred()) + + recheckedNode := &corev1.Node{} + Expect(k8sClient.Get(ctx, types.NamespacedName{Name: "repro-node"}, recheckedNode)).To(Succeed()) + + // The bootstrap annotation key uses the rule's UID as the suffix, + // so even very long rule names never violate the 63-char key limit. + uidKey := bootstrapAnnotationKey(rule.GetUID()) + Expect(recheckedNode.Annotations).To(HaveKey(uidKey), + "UID-based bootstrap annotation should be present on the node") + + // The annotation value should contain the full rule name for readability. + Expect(recheckedNode.Annotations[uidKey]).To(ContainSubstring(longRuleName), + "annotation value should contain the full rule name for human readability") + }) + }) + + +}) diff --git a/internal/controller/node_controller_test.go b/internal/controller/node_controller_test.go index 8557cff..51f2723 100644 --- a/internal/controller/node_controller_test.go +++ b/internal/controller/node_controller_test.go @@ -105,6 +105,77 @@ var _ = Describe("Node Controller", func() { }) }) + Context("isBootstrapCompleted tests", func() { + var ( + ctx context.Context + readinessController *RuleReadinessController + node *corev1.Node + ruleUID types.UID + ruleName string + nodeName string + ) + + BeforeEach(func() { + ctx = context.Background() + ruleUID = types.UID("test-rule-uid-1234") + ruleName = "test-rule" + nodeName = "bootstrap-test-node" + + readinessController = &RuleReadinessController{ + Client: k8sClient, + } + + node = &corev1.Node{ + ObjectMeta: metav1.ObjectMeta{ + Name: nodeName, + }, + } + Expect(k8sClient.Create(ctx, node)).To(Succeed()) + }) + + AfterEach(func() { + _ = k8sClient.Delete(ctx, node) + }) + + It("should return false if no annotations exist", func() { + Expect(readinessController.isBootstrapCompleted(ctx, nodeName, ruleName, ruleUID)).To(BeFalse()) + }) + + It("should return true if only new annotation exists", func() { + updatedNode := &corev1.Node{} + Expect(k8sClient.Get(ctx, types.NamespacedName{Name: nodeName}, updatedNode)).To(Succeed()) + updatedNode.Annotations = map[string]string{ + bootstrapAnnotationKey(ruleUID): bootstrapAnnotationValue(ruleName), + } + Expect(k8sClient.Update(ctx, updatedNode)).To(Succeed()) + + Expect(readinessController.isBootstrapCompleted(ctx, nodeName, ruleName, ruleUID)).To(BeTrue()) + }) + + It("should return true if only legacy annotation exists", func() { + updatedNode := &corev1.Node{} + Expect(k8sClient.Get(ctx, types.NamespacedName{Name: nodeName}, updatedNode)).To(Succeed()) + updatedNode.Annotations = map[string]string{ + legacyBootstrapAnnotationKey(ruleName): bootstrapAnnotationValue(ruleName), + } + Expect(k8sClient.Update(ctx, updatedNode)).To(Succeed()) + + Expect(readinessController.isBootstrapCompleted(ctx, nodeName, ruleName, ruleUID)).To(BeTrue()) + }) + + It("should return true if both annotations exist", func() { + updatedNode := &corev1.Node{} + Expect(k8sClient.Get(ctx, types.NamespacedName{Name: nodeName}, updatedNode)).To(Succeed()) + updatedNode.Annotations = map[string]string{ + bootstrapAnnotationKey(ruleUID): bootstrapAnnotationValue(ruleName), + legacyBootstrapAnnotationKey(ruleName): bootstrapAnnotationValue(ruleName), + } + Expect(k8sClient.Update(ctx, updatedNode)).To(Succeed()) + + Expect(readinessController.isBootstrapCompleted(ctx, nodeName, ruleName, ruleUID)).To(BeTrue()) + }) + }) + // Reconciliation tests need cluster resources Context("when reconciling a node", func() { var ( @@ -232,12 +303,12 @@ var _ = Describe("Node Controller", func() { return false }, time.Second*5).Should(BeFalse()) - // Verify bootstrap completion annotation is added + // Verify bootstrap completion annotation is added (UID-based key) Eventually(func() map[string]string { updatedNode := &corev1.Node{} _ = k8sClient.Get(ctx, namespacedName, updatedNode) return updatedNode.Annotations - }).Should(HaveKey("readiness.k8s.io/bootstrap-completed-" + ruleName)) + }).Should(HaveKey(bootstrapAnnotationKey(rule.GetUID()))) }) It("should not re-add the taint if conditions regress after completion", func() { diff --git a/internal/controller/nodereadinessrule_controller.go b/internal/controller/nodereadinessrule_controller.go index d73cf4a..50943b7 100644 --- a/internal/controller/nodereadinessrule_controller.go +++ b/internal/controller/nodereadinessrule_controller.go @@ -408,7 +408,7 @@ func (r *RuleReadinessController) evaluateRuleForNode(ctx context.Context, rule // Mark bootstrap completed if bootstrap-only mode if rule.Spec.EnforcementMode == readinessv1alpha1.EnforcementModeBootstrapOnly { - r.markBootstrapCompleted(ctx, node.Name, rule.Name) + r.markBootstrapCompleted(ctx, node.Name, rule.Name, rule.GetUID()) // Only record the bootstrap duration if the node was created AFTER the rule. // This prevents legacy nodes from poisoning the histogram with massive outliers. @@ -449,6 +449,10 @@ func (r *RuleReadinessController) evaluateRuleForNode(ctx context.Context, rule default: log.Info("No taint action needed", "node", node.Name, "rule", rule.Name, "shouldRemove", shouldRemoveTaint, "hasTaint", currentlyHasTaint) + // Mark bootstrap completed if bootstrap-only mode + if rule.Spec.EnforcementMode == readinessv1alpha1.EnforcementModeBootstrapOnly { + r.markBootstrapCompleted(ctx, node.Name, rule.Name, rule.GetUID()) + } } // Determine observed taint status after any actions diff --git a/internal/controller/nodereadinessrule_controller_test.go b/internal/controller/nodereadinessrule_controller_test.go index f16d109..2b9ca97 100644 --- a/internal/controller/nodereadinessrule_controller_test.go +++ b/internal/controller/nodereadinessrule_controller_test.go @@ -1147,9 +1147,10 @@ var _ = Describe("NodeReadinessRule Controller", func() { It("should handle bootstrap completion tracking", func() { nodeName := "bootstrap-test-node" ruleName := "bootstrap-test-rule" + ruleUID := types.UID("11111111-1111-1111-1111-111111111111") // Initially not completed - completed := readinessController.isBootstrapCompleted(ctx, nodeName, ruleName) + completed := readinessController.isBootstrapCompleted(ctx, nodeName, ruleName, ruleUID) Expect(completed).To(BeFalse()) // Create a node for testing @@ -1162,24 +1163,25 @@ var _ = Describe("NodeReadinessRule Controller", func() { defer func() { _ = k8sClient.Delete(ctx, node) }() // Mark as completed - readinessController.markBootstrapCompleted(ctx, nodeName, ruleName) + readinessController.markBootstrapCompleted(ctx, nodeName, ruleName, ruleUID) // Should now be completed Eventually(func() bool { - return readinessController.isBootstrapCompleted(ctx, nodeName, ruleName) + return readinessController.isBootstrapCompleted(ctx, nodeName, ruleName, ruleUID) }).Should(BeTrue()) }) It("should return false when context is cancelled", func() { nodeName := "bootstrap-ctx-test-node" ruleName := "bootstrap-ctx-test-rule" + ruleUID := types.UID("22222222-2222-2222-2222-222222222222") - // Create a node with the bootstrap annotation already set + // Create a node with the UID-based bootstrap annotation already set node := &corev1.Node{ ObjectMeta: metav1.ObjectMeta{ Name: nodeName, Annotations: map[string]string{ - "readiness.k8s.io/bootstrap-completed-" + ruleName: "true", + bootstrapAnnotationKey(ruleUID): `{"rule-name":"bootstrap-ctx-test-rule"}`, }, }, } @@ -1187,17 +1189,18 @@ var _ = Describe("NodeReadinessRule Controller", func() { defer func() { _ = k8sClient.Delete(ctx, node) }() // Verify it returns true with a valid context - Expect(readinessController.isBootstrapCompleted(ctx, nodeName, ruleName)).To(BeTrue()) + Expect(readinessController.isBootstrapCompleted(ctx, nodeName, ruleName, ruleUID)).To(BeTrue()) // A cancelled context should cause the Get to fail, returning false cancelledCtx, cancel := context.WithCancel(ctx) cancel() - Expect(readinessController.isBootstrapCompleted(cancelledCtx, nodeName, ruleName)).To(BeFalse()) + Expect(readinessController.isBootstrapCompleted(cancelledCtx, nodeName, ruleName, ruleUID)).To(BeFalse()) }) It("should set bootstrap annotation via patch in markBootstrapCompleted", func() { nodeName := "bootstrap-patch-test-node" ruleName := "bootstrap-patch-test-rule" + ruleUID := types.UID("33333333-3333-3333-3333-333333333333") // Create a node with existing annotations that should be preserved node := &corev1.Node{ @@ -1212,14 +1215,16 @@ var _ = Describe("NodeReadinessRule Controller", func() { defer func() { _ = k8sClient.Delete(ctx, node) }() // Mark bootstrap completed - readinessController.markBootstrapCompleted(ctx, nodeName, ruleName) + readinessController.markBootstrapCompleted(ctx, nodeName, ruleName, ruleUID) - // Verify annotation was added and existing annotation is preserved + // Verify UID-based annotation was added and existing annotation is preserved Eventually(func(g Gomega) { updatedNode := &corev1.Node{} g.Expect(k8sClient.Get(ctx, types.NamespacedName{Name: nodeName}, updatedNode)).To(Succeed()) - g.Expect(updatedNode.Annotations).To(HaveKeyWithValue( - "readiness.k8s.io/bootstrap-completed-"+ruleName, "true")) + g.Expect(updatedNode.Annotations).To(HaveKey( + bootstrapAnnotationKey(ruleUID))) + g.Expect(updatedNode.Annotations[bootstrapAnnotationKey(ruleUID)]).To( + ContainSubstring(ruleName)) g.Expect(updatedNode.Annotations).To(HaveKeyWithValue( "existing-annotation", "should-be-preserved")) }).Should(Succeed()) @@ -1228,6 +1233,7 @@ var _ = Describe("NodeReadinessRule Controller", func() { It("should increment bootstrap completed metric only when newly marked", func() { nodeName := "bootstrap-metric-test-node" ruleName := "bootstrap-metric-test-rule" + ruleUID := types.UID("44444444-4444-4444-4444-444444444444") node := &corev1.Node{ ObjectMeta: metav1.ObjectMeta{ @@ -1240,8 +1246,8 @@ var _ = Describe("NodeReadinessRule Controller", func() { counter := metrics.BootstrapCompleted.WithLabelValues(ruleName) before := counterValue(counter) - readinessController.markBootstrapCompleted(ctx, nodeName, ruleName) - readinessController.markBootstrapCompleted(ctx, nodeName, ruleName) + readinessController.markBootstrapCompleted(ctx, nodeName, ruleName, ruleUID) + readinessController.markBootstrapCompleted(ctx, nodeName, ruleName, ruleUID) Expect(counterValue(counter)).To(Equal(before + 1)) })