Skip to content

Commit 69c4138

Browse files
authored
Add unit tests for shard lease resource lock (#478)
1 parent 11f34ed commit 69c4138

File tree

3 files changed

+335
-9
lines changed

3 files changed

+335
-9
lines changed

pkg/shard/lease/lease.go

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import (
2121
"encoding/json"
2222
"errors"
2323
"fmt"
24+
"io/fs"
2425
"os"
2526

2627
coordinationv1 "k8s.io/api/coordination/v1"
@@ -60,6 +61,10 @@ func NewResourceLock(config *rest.Config, eventRecorder resourcelock.EventRecord
6061
return nil, err
6162
}
6263

64+
if options.ControllerRingName == "" {
65+
return nil, errors.New("ControllerRingName is required")
66+
}
67+
6368
// default shard name to hostname if not set
6469
if options.ShardName == "" {
6570
options.ShardName, err = os.Hostname()
@@ -76,7 +81,7 @@ func NewResourceLock(config *rest.Config, eventRecorder resourcelock.EventRecord
7681
}
7782
}
7883

79-
ll := &LeaseLock{
84+
return &LeaseLock{
8085
LeaseMeta: metav1.ObjectMeta{
8186
Namespace: options.LeaseNamespace,
8287
Name: options.ShardName,
@@ -90,9 +95,7 @@ func NewResourceLock(config *rest.Config, eventRecorder resourcelock.EventRecord
9095
Labels: map[string]string{
9196
shardingv1alpha1.LabelControllerRing: options.ControllerRingName,
9297
},
93-
}
94-
95-
return ll, nil
98+
}, nil
9699
}
97100

98101
// LeaseLock implements resourcelock.Interface but is able to add labels to the Lease.
@@ -140,7 +143,7 @@ func (ll *LeaseLock) Create(ctx context.Context, ler resourcelock.LeaderElection
140143
// Update will update an existing Lease spec.
141144
func (ll *LeaseLock) Update(ctx context.Context, ler resourcelock.LeaderElectionRecord) error {
142145
if ll.lease == nil {
143-
return errors.New("lease not initialized, call get or create first")
146+
return errors.New("lease not initialized, call Get or Create first")
144147
}
145148
// don't set labels map on ll.lease directly, otherwise we might overwrite labels managed by the sharder
146149
mergeLabels(&ll.lease.ObjectMeta, ll.Labels)
@@ -185,19 +188,25 @@ func mergeLabels(obj *metav1.ObjectMeta, labels map[string]string) {
185188
}
186189
}
187190

188-
const inClusterNamespacePath = "/var/run/secrets/kubernetes.io/serviceaccount/namespace"
191+
// allow overwriting file system for testing purposes
192+
var fsys = os.DirFS("/")
193+
194+
const inClusterNamespacePath = "var/run/secrets/kubernetes.io/serviceaccount/namespace"
189195

196+
// getInClusterNamespace determines the namespace that this binary is running in, if running in a cluster.
197+
// For this, it consults the ServiceAccount mount. If the binary is not running in a cluster or the pod doesn't mount a
198+
// ServiceAccount, it returns an error.
190199
func getInClusterNamespace() (string, error) {
191200
// Check whether the namespace file exists.
192201
// If not, we are not running in cluster so can't guess the namespace.
193-
if _, err := os.Stat(inClusterNamespacePath); os.IsNotExist(err) {
194-
return "", fmt.Errorf("not running in-cluster, please specify LeaseNamespace")
202+
if _, err := fs.Stat(fsys, inClusterNamespacePath); os.IsNotExist(err) {
203+
return "", fmt.Errorf("not running in cluster, please specify LeaseNamespace")
195204
} else if err != nil {
196205
return "", fmt.Errorf("error checking namespace file: %w", err)
197206
}
198207

199208
// Load the namespace file and return its content
200-
namespace, err := os.ReadFile(inClusterNamespacePath)
209+
namespace, err := fs.ReadFile(fsys, inClusterNamespacePath)
201210
if err != nil {
202211
return "", fmt.Errorf("error reading namespace file: %w", err)
203212
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
/*
2+
Copyright 2025 Tim Ebert.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package lease_test
18+
19+
import (
20+
"testing"
21+
22+
. "github.com/onsi/ginkgo/v2"
23+
. "github.com/onsi/gomega"
24+
)
25+
26+
func TestLease(t *testing.T) {
27+
RegisterFailHandler(Fail)
28+
RunSpecs(t, "Shard Library Lease Suite")
29+
}

pkg/shard/lease/lease_test.go

Lines changed: 288 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,288 @@
1+
/*
2+
Copyright 2025 Tim Ebert.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package lease
18+
19+
import (
20+
"context"
21+
"os"
22+
"testing/fstest"
23+
24+
. "github.com/onsi/ginkgo/v2"
25+
. "github.com/onsi/gomega"
26+
coordinationv1 "k8s.io/api/coordination/v1"
27+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
28+
"k8s.io/client-go/kubernetes/fake"
29+
coordinationv1client "k8s.io/client-go/kubernetes/typed/coordination/v1"
30+
"k8s.io/client-go/rest"
31+
"k8s.io/client-go/tools/leaderelection/resourcelock"
32+
"k8s.io/client-go/tools/record"
33+
"k8s.io/utils/ptr"
34+
"sigs.k8s.io/controller-runtime/pkg/client"
35+
36+
. "github.com/timebertt/kubernetes-controller-sharding/pkg/utils/test/matchers"
37+
)
38+
39+
var _ = Describe("LeaseLock", func() {
40+
const (
41+
namespace = "default"
42+
controllerRingName = "operator"
43+
shardName = "operator-a"
44+
)
45+
46+
var ctx context.Context
47+
48+
BeforeEach(func() {
49+
ctx = context.Background()
50+
51+
fsys = fstest.MapFS{
52+
"var/run/secrets/kubernetes.io/serviceaccount/namespace": &fstest.MapFile{
53+
Data: []byte(namespace),
54+
},
55+
}
56+
})
57+
58+
Describe("#NewResourceLock", func() {
59+
var (
60+
restConfig *rest.Config
61+
62+
options Options
63+
)
64+
65+
BeforeEach(func() {
66+
restConfig = &rest.Config{}
67+
68+
options = Options{
69+
ControllerRingName: controllerRingName,
70+
LeaseNamespace: "operator-system",
71+
ShardName: shardName,
72+
}
73+
})
74+
75+
It("should fail if ControllerRingName is empty", func() {
76+
options.ControllerRingName = ""
77+
78+
Expect(NewResourceLock(restConfig, nil, options)).Error().To(MatchError("ControllerRingName is required"))
79+
})
80+
81+
It("should use the configured namespace and name", func() {
82+
resourceLock, err := NewResourceLock(restConfig, nil, options)
83+
Expect(err).NotTo(HaveOccurred())
84+
85+
leaseLock := resourceLock.(*LeaseLock)
86+
Expect(leaseLock.LeaseMeta.Namespace).To(Equal(options.LeaseNamespace))
87+
Expect(leaseLock.LeaseMeta.Name).To(Equal(options.ShardName))
88+
Expect(leaseLock.Identity()).To(Equal(leaseLock.LeaseMeta.Name), "identity should equal the shard name")
89+
})
90+
91+
It("should default the name to the hostname", func() {
92+
options.ShardName = ""
93+
hostname, err := os.Hostname()
94+
Expect(err).NotTo(HaveOccurred())
95+
96+
resourceLock, err := NewResourceLock(restConfig, nil, options)
97+
Expect(err).NotTo(HaveOccurred())
98+
99+
leaseLock := resourceLock.(*LeaseLock)
100+
Expect(leaseLock.LeaseMeta.Name).To(Equal(hostname))
101+
Expect(leaseLock.Identity()).To(Equal(leaseLock.LeaseMeta.Name), "identity should equal the shard name")
102+
})
103+
104+
It("should default the namespace to the in-cluster namespace", func() {
105+
options.LeaseNamespace = ""
106+
107+
resourceLock, err := NewResourceLock(restConfig, nil, options)
108+
Expect(err).NotTo(HaveOccurred())
109+
110+
leaseLock := resourceLock.(*LeaseLock)
111+
Expect(leaseLock.LeaseMeta.Namespace).To(Equal(namespace))
112+
})
113+
114+
It("should fail if the in-cluster namespace cannot be determined", func() {
115+
options.LeaseNamespace = ""
116+
fsys = fstest.MapFS{}
117+
118+
Expect(NewResourceLock(restConfig, nil, options)).Error().To(MatchError(And(
119+
ContainSubstring("not running in cluster"),
120+
ContainSubstring("please specify LeaseNamespace"),
121+
)))
122+
})
123+
})
124+
125+
Describe("#LeaseLock", func() {
126+
var (
127+
lock resourcelock.Interface
128+
fakeClient coordinationv1client.LeasesGetter
129+
130+
lease *coordinationv1.Lease
131+
)
132+
133+
BeforeEach(func() {
134+
var err error
135+
lock, err = NewResourceLock(&rest.Config{}, nil, Options{
136+
ControllerRingName: controllerRingName,
137+
LeaseNamespace: namespace,
138+
ShardName: shardName,
139+
})
140+
Expect(err).NotTo(HaveOccurred())
141+
142+
fakeClient = fake.NewClientset().CoordinationV1()
143+
lock.(*LeaseLock).Client = fakeClient
144+
145+
lease = &coordinationv1.Lease{
146+
ObjectMeta: metav1.ObjectMeta{
147+
Namespace: namespace,
148+
Name: shardName,
149+
},
150+
Spec: coordinationv1.LeaseSpec{
151+
HolderIdentity: ptr.To(shardName),
152+
},
153+
}
154+
Expect(fakeClient.Leases(lease.Namespace).Create(ctx, lease, metav1.CreateOptions{})).Error().To(Succeed())
155+
})
156+
157+
Describe("#Get", func() {
158+
It("should return NotFound if the lease does not exist", func() {
159+
Expect(fakeClient.Leases(lease.Namespace).Delete(ctx, lease.Name, metav1.DeleteOptions{})).To(Succeed())
160+
Expect(lock.Get(ctx)).Error().To(BeNotFoundError())
161+
})
162+
163+
It("should return the existing lease", func() {
164+
record, _, err := lock.Get(ctx)
165+
Expect(err).NotTo(HaveOccurred())
166+
Expect(record).NotTo(BeNil())
167+
Expect(record.HolderIdentity).To(Equal(*lease.Spec.HolderIdentity))
168+
})
169+
})
170+
171+
Describe("#Create", func() {
172+
It("should create the lease if it does not exist", func() {
173+
Expect(fakeClient.Leases(lease.Namespace).Delete(ctx, lease.Name, metav1.DeleteOptions{})).To(Succeed())
174+
175+
Expect(lock.Create(ctx, resourcelock.LeaderElectionRecord{
176+
HolderIdentity: "foo",
177+
})).To(Succeed())
178+
179+
Expect(fakeClient.Leases(lease.Namespace).Get(ctx, lease.Name, metav1.GetOptions{})).To(And(
180+
HaveField("ObjectMeta", And(
181+
HaveField("Namespace", Equal(namespace)),
182+
HaveField("Name", Equal(shardName)),
183+
HaveField("Labels", Equal(map[string]string{
184+
"alpha.sharding.timebertt.dev/controllerring": controllerRingName,
185+
})),
186+
)),
187+
HaveField("Spec.HolderIdentity", Equal(ptr.To("foo"))),
188+
))
189+
})
190+
})
191+
192+
Describe("#Update", func() {
193+
It("should fail if lock is not initialized yet", func() {
194+
Expect(lock.Update(ctx, resourcelock.LeaderElectionRecord{
195+
HolderIdentity: "foo",
196+
})).To(MatchError(ContainSubstring("not initialized")))
197+
})
198+
199+
It("should update the lease", func() {
200+
Expect(lock.Get(ctx)).Error().To(Succeed())
201+
202+
Expect(lock.Update(ctx, resourcelock.LeaderElectionRecord{
203+
HolderIdentity: "foo",
204+
})).To(Succeed())
205+
206+
Expect(fakeClient.Leases(lease.Namespace).Get(ctx, lease.Name, metav1.GetOptions{})).To(And(
207+
HaveField("ObjectMeta", And(
208+
HaveField("Namespace", Equal(namespace)),
209+
HaveField("Name", Equal(shardName)),
210+
HaveField("Labels", Equal(map[string]string{
211+
"alpha.sharding.timebertt.dev/controllerring": controllerRingName,
212+
})),
213+
)),
214+
HaveField("Spec.HolderIdentity", Equal(ptr.To("foo"))),
215+
))
216+
})
217+
218+
It("should keep externally managed labels", func() {
219+
metav1.SetMetaDataLabel(&lease.ObjectMeta, "foo", "bar")
220+
Expect(fakeClient.Leases(lease.Namespace).Update(ctx, lease, metav1.UpdateOptions{})).Error().To(Succeed())
221+
222+
Expect(lock.Get(ctx)).Error().To(Succeed())
223+
224+
Expect(lock.Update(ctx, resourcelock.LeaderElectionRecord{
225+
HolderIdentity: "foo",
226+
})).To(Succeed())
227+
228+
Expect(fakeClient.Leases(lease.Namespace).Get(ctx, lease.Name, metav1.GetOptions{})).To(
229+
HaveField("ObjectMeta.Labels", Equal(map[string]string{
230+
"foo": "bar",
231+
"alpha.sharding.timebertt.dev/controllerring": controllerRingName,
232+
})),
233+
)
234+
})
235+
})
236+
237+
Describe("#RecordEvent", func() {
238+
Context("no EventRecorder configured", func() {
239+
It("should do nothing", func() {
240+
lock.RecordEvent("foo")
241+
})
242+
})
243+
244+
Context("EventRecorder configured", func() {
245+
var recorder *record.FakeRecorder
246+
247+
BeforeEach(func() {
248+
recorder = record.NewFakeRecorder(1)
249+
lock.(*LeaseLock).LockConfig.EventRecorder = recorder
250+
})
251+
252+
It("should send the event", func() {
253+
Expect(lock.Get(ctx)).Error().To(Succeed())
254+
255+
lock.RecordEvent("foo")
256+
257+
Eventually(recorder.Events).Should(Receive(
258+
Equal("Normal LeaderElection " + shardName + " foo"),
259+
))
260+
})
261+
})
262+
})
263+
264+
Describe("#Describe", func() {
265+
It("should return the lease key", func() {
266+
Expect(lock.Describe()).To(Equal(client.ObjectKeyFromObject(lease).String()))
267+
})
268+
})
269+
270+
Describe("#Identity()", func() {
271+
It("should return the lease name", func() {
272+
Expect(lock.Identity()).To(Equal(lease.Name))
273+
})
274+
})
275+
})
276+
277+
Describe("#getInClusterNamespace", func() {
278+
It("should fail because namespace file does not exist", func() {
279+
fsys = fstest.MapFS{}
280+
281+
Expect(getInClusterNamespace()).Error().To(MatchError(ContainSubstring("not running in cluster")))
282+
})
283+
284+
It("should return content of namespace file", func() {
285+
Expect(getInClusterNamespace()).To(Equal(namespace))
286+
})
287+
})
288+
})

0 commit comments

Comments
 (0)