A Kubernetes operator that bridges Hardware Security Module (HSM) data storage with Kubernetes Secrets, providing true secret portability th
1/*
2Copyright 2025.
3
4Licensed under the Apache License, Version 2.0 (the "License");
5you may not use this file except in compliance with the License.
6You may obtain a copy of the License at
7
8 http://www.apache.org/licenses/LICENSE-2.0
9
10Unless required by applicable law or agreed to in writing, software
11distributed under the License is distributed on an "AS IS" BASIS,
12WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13See the License for the specific language governing permissions and
14limitations under the License.
15*/
16
17package controller
18
19import (
20 "context"
21 "fmt"
22
23 . "github.com/onsi/ginkgo/v2"
24 . "github.com/onsi/gomega"
25 appsv1 "k8s.io/api/apps/v1"
26 corev1 "k8s.io/api/core/v1"
27 "k8s.io/apimachinery/pkg/api/errors"
28 metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
29 "k8s.io/apimachinery/pkg/types"
30 "k8s.io/apimachinery/pkg/util/intstr"
31 ctrl "sigs.k8s.io/controller-runtime"
32
33 hsmv1alpha1 "github.com/evanjarrett/hsm-secrets-operator/api/v1alpha1"
34 "github.com/evanjarrett/hsm-secrets-operator/internal/config"
35)
36
37var _ = Describe("DiscoveryDaemonSetReconciler", func() {
38 Context("When reconciling an HSMDevice", func() {
39 var (
40 hsmDeviceName string
41 hsmDeviceNamespace = "default"
42 discoveryImage = "test-discovery:latest"
43 )
44
45 ctx := context.Background()
46 var hsmDevice *hsmv1alpha1.HSMDevice
47
48 BeforeEach(func() {
49 // Generate unique resource name for each test
50 hsmDeviceName = fmt.Sprintf("test-device-%d", GinkgoRandomSeed())
51 hsmDevice = &hsmv1alpha1.HSMDevice{
52 ObjectMeta: metav1.ObjectMeta{
53 Name: hsmDeviceName,
54 Namespace: hsmDeviceNamespace,
55 },
56 Spec: hsmv1alpha1.HSMDeviceSpec{
57 DeviceType: "PicoHSM",
58 Discovery: &hsmv1alpha1.DiscoverySpec{
59 USB: &hsmv1alpha1.USBDeviceSpec{
60 VendorID: "20a0",
61 ProductID: "4230",
62 },
63 },
64 PKCS11: &hsmv1alpha1.PKCS11Config{
65 LibraryPath: "/usr/lib/libsc-hsm-pkcs11.so",
66 SlotId: 0,
67 PinSecret: &hsmv1alpha1.SecretKeySelector{
68 Name: "test-pin",
69 Key: "pin",
70 },
71 },
72 NodeSelector: map[string]string{
73 "hsm-type": "pico",
74 },
75 },
76 }
77 })
78
79 AfterEach(func() {
80 // Clean up HSMDevice if it exists
81 if hsmDevice != nil {
82 _ = k8sClient.Delete(ctx, hsmDevice)
83 }
84 })
85
86 It("Should create a DaemonSet when HSMDevice is created", func() {
87 By("Creating the HSMDevice")
88 Expect(k8sClient.Create(ctx, hsmDevice)).To(Succeed())
89
90 By("Reconciling the HSMDevice")
91 reconciler := &DiscoveryDaemonSetReconciler{
92 Client: k8sClient,
93 Scheme: k8sClient.Scheme(),
94 ImageResolver: config.NewImageResolver(k8sClient),
95 DiscoveryImage: discoveryImage,
96 ServiceAccountName: "hsm-secrets-operator",
97 }
98
99 _, err := reconciler.Reconcile(ctx, ctrl.Request{
100 NamespacedName: types.NamespacedName{
101 Name: hsmDeviceName,
102 Namespace: hsmDeviceNamespace,
103 },
104 })
105 Expect(err).NotTo(HaveOccurred())
106
107 By("Checking that the DaemonSet was created")
108 daemonSetName := fmt.Sprintf("%s-discovery", hsmDeviceName)
109 daemonSet := &appsv1.DaemonSet{}
110 Eventually(func() error {
111 return k8sClient.Get(ctx, types.NamespacedName{
112 Name: daemonSetName,
113 Namespace: hsmDeviceNamespace,
114 }, daemonSet)
115 }).Should(Succeed())
116
117 By("Verifying DaemonSet configuration")
118 Expect(daemonSet.Name).To(Equal(daemonSetName))
119 Expect(daemonSet.Namespace).To(Equal(hsmDeviceNamespace))
120
121 // Check labels
122 Expect(daemonSet.Labels).To(HaveKeyWithValue("app.kubernetes.io/name", "hsm-secrets-operator"))
123 Expect(daemonSet.Labels).To(HaveKeyWithValue("app.kubernetes.io/component", "discovery"))
124 Expect(daemonSet.Labels).To(HaveKeyWithValue("hsm.j5t.io/device", hsmDeviceName))
125
126 // Check owner reference
127 Expect(daemonSet.OwnerReferences).To(HaveLen(1))
128 Expect(daemonSet.OwnerReferences[0].Name).To(Equal(hsmDeviceName))
129 Expect(daemonSet.OwnerReferences[0].Kind).To(Equal("HSMDevice"))
130
131 // Check pod template
132 podSpec := daemonSet.Spec.Template.Spec
133 Expect(podSpec.ServiceAccountName).To(Equal(reconciler.ServiceAccountName))
134 Expect(podSpec.Containers).To(HaveLen(1))
135
136 container := podSpec.Containers[0]
137 Expect(container.Name).To(Equal("discovery"))
138 Expect(container.Image).To(Equal(discoveryImage))
139 Expect(container.Args).To(ContainElement("--mode=discovery"))
140
141 // Check environment variables
142 envVars := container.Env
143 var nodeNameEnv, podNamespaceEnv, podNameEnv *corev1.EnvVar
144 for i := range envVars {
145 switch envVars[i].Name {
146 case "NODE_NAME":
147 nodeNameEnv = &envVars[i]
148 case "POD_NAMESPACE":
149 podNamespaceEnv = &envVars[i]
150 case "POD_NAME":
151 podNameEnv = &envVars[i]
152 }
153 }
154
155 Expect(nodeNameEnv).NotTo(BeNil())
156 Expect(nodeNameEnv.ValueFrom.FieldRef.FieldPath).To(Equal("spec.nodeName"))
157 Expect(podNamespaceEnv).NotTo(BeNil())
158 Expect(podNamespaceEnv.ValueFrom.FieldRef.FieldPath).To(Equal("metadata.namespace"))
159 Expect(podNameEnv).NotTo(BeNil())
160 Expect(podNameEnv.ValueFrom.FieldRef.FieldPath).To(Equal("metadata.name"))
161
162 // Check volumes - includes discovery volumes and kubelet device plugin volumes
163 Expect(podSpec.Volumes).To(HaveLen(5))
164 var devVolume, sysVolume, udevVolume, devicePluginsVolume, pluginsRegistryVolume *corev1.Volume
165 for i := range podSpec.Volumes {
166 switch podSpec.Volumes[i].Name {
167 case "dev":
168 devVolume = &podSpec.Volumes[i]
169 case "sys":
170 sysVolume = &podSpec.Volumes[i]
171 case "run-udev":
172 udevVolume = &podSpec.Volumes[i]
173 case "device-plugins":
174 devicePluginsVolume = &podSpec.Volumes[i]
175 case "plugins-registry":
176 pluginsRegistryVolume = &podSpec.Volumes[i]
177 }
178 }
179
180 Expect(devVolume).NotTo(BeNil())
181 Expect(sysVolume).NotTo(BeNil())
182 Expect(udevVolume).NotTo(BeNil())
183 Expect(devicePluginsVolume).NotTo(BeNil())
184 Expect(pluginsRegistryVolume).NotTo(BeNil())
185
186 // In CI environments, volumes use EmptyDir; in production they use HostPath
187 if devVolume.HostPath != nil {
188 // Production environment - expect HostPath volumes
189 Expect(devVolume.HostPath.Path).To(Equal("/dev"))
190 Expect(sysVolume.HostPath.Path).To(Equal("/sys"))
191 Expect(udevVolume.HostPath.Path).To(Equal("/run/udev"))
192 Expect(devicePluginsVolume.HostPath.Path).To(Equal("/var/lib/kubelet/device-plugins"))
193 Expect(pluginsRegistryVolume.HostPath.Path).To(Equal("/var/lib/kubelet/plugins_registry"))
194 } else {
195 // CI/test environment - expect EmptyDir volumes
196 Expect(devVolume.EmptyDir).NotTo(BeNil())
197 Expect(sysVolume.EmptyDir).NotTo(BeNil())
198 Expect(udevVolume.EmptyDir).NotTo(BeNil())
199 }
200
201 // Check node selector from HSMDevice
202 Expect(podSpec.NodeSelector).To(HaveKeyWithValue("hsm-type", "pico"))
203
204 // Check update strategy
205 Expect(daemonSet.Spec.UpdateStrategy.Type).To(Equal(appsv1.RollingUpdateDaemonSetStrategyType))
206 Expect(daemonSet.Spec.UpdateStrategy.RollingUpdate.MaxUnavailable).To(Equal(&intstr.IntOrString{Type: intstr.String, StrVal: "50%"}))
207 })
208
209 It("Should update DaemonSet when HSMDevice is updated", func() {
210 By("Creating the HSMDevice")
211 Expect(k8sClient.Create(ctx, hsmDevice)).To(Succeed())
212
213 By("Reconciling to create initial DaemonSet")
214 reconciler := &DiscoveryDaemonSetReconciler{
215 Client: k8sClient,
216 Scheme: k8sClient.Scheme(),
217 ImageResolver: config.NewImageResolver(k8sClient),
218 }
219
220 _, err := reconciler.Reconcile(ctx, ctrl.Request{
221 NamespacedName: types.NamespacedName{
222 Name: hsmDeviceName,
223 Namespace: hsmDeviceNamespace,
224 },
225 })
226 Expect(err).NotTo(HaveOccurred())
227
228 By("Updating the HSMDevice node selector")
229 Eventually(func() error {
230 device := &hsmv1alpha1.HSMDevice{}
231 if err := k8sClient.Get(ctx, types.NamespacedName{
232 Name: hsmDeviceName,
233 Namespace: hsmDeviceNamespace,
234 }, device); err != nil {
235 return err
236 }
237
238 device.Spec.NodeSelector = map[string]string{
239 "hsm-type": "updated",
240 "zone": "us-west-1",
241 }
242 return k8sClient.Update(ctx, device)
243 }).Should(Succeed())
244
245 By("Reconciling after update")
246 _, err = reconciler.Reconcile(ctx, ctrl.Request{
247 NamespacedName: types.NamespacedName{
248 Name: hsmDeviceName,
249 Namespace: hsmDeviceNamespace,
250 },
251 })
252 Expect(err).NotTo(HaveOccurred())
253
254 By("Checking that DaemonSet was updated")
255 daemonSetName := fmt.Sprintf("%s-discovery", hsmDeviceName)
256 daemonSet := &appsv1.DaemonSet{}
257 Eventually(func() map[string]string {
258 _ = k8sClient.Get(ctx, types.NamespacedName{
259 Name: daemonSetName,
260 Namespace: hsmDeviceNamespace,
261 }, daemonSet)
262 return daemonSet.Spec.Template.Spec.NodeSelector
263 }).Should(And(
264 HaveKeyWithValue("hsm-type", "updated"),
265 HaveKeyWithValue("zone", "us-west-1"),
266 ))
267 })
268
269 It("Should clean up DaemonSet when HSMDevice is deleted", func() {
270 By("Creating the HSMDevice")
271 Expect(k8sClient.Create(ctx, hsmDevice)).To(Succeed())
272
273 By("Reconciling to create DaemonSet")
274 reconciler := &DiscoveryDaemonSetReconciler{
275 Client: k8sClient,
276 Scheme: k8sClient.Scheme(),
277 ImageResolver: config.NewImageResolver(k8sClient),
278 }
279
280 _, err := reconciler.Reconcile(ctx, ctrl.Request{
281 NamespacedName: types.NamespacedName{
282 Name: hsmDeviceName,
283 Namespace: hsmDeviceNamespace,
284 },
285 })
286 Expect(err).NotTo(HaveOccurred())
287
288 By("Verifying DaemonSet exists")
289 daemonSetName := fmt.Sprintf("%s-discovery", hsmDeviceName)
290 daemonSet := &appsv1.DaemonSet{}
291 Eventually(func() error {
292 return k8sClient.Get(ctx, types.NamespacedName{
293 Name: daemonSetName,
294 Namespace: hsmDeviceNamespace,
295 }, daemonSet)
296 }).Should(Succeed())
297
298 By("Deleting the HSMDevice")
299 Expect(k8sClient.Delete(ctx, hsmDevice)).To(Succeed())
300
301 By("Reconciling after deletion")
302 _, err = reconciler.Reconcile(ctx, ctrl.Request{
303 NamespacedName: types.NamespacedName{
304 Name: hsmDeviceName,
305 Namespace: hsmDeviceNamespace,
306 },
307 })
308 Expect(err).NotTo(HaveOccurred())
309
310 By("Verifying DaemonSet is deleted by garbage collection")
311 // Note: The DaemonSet should be deleted by Kubernetes garbage collection
312 // due to owner references, but in test environment this might need time
313 Eventually(func() bool {
314 err := k8sClient.Get(ctx, types.NamespacedName{
315 Name: daemonSetName,
316 Namespace: hsmDeviceNamespace,
317 }, daemonSet)
318 return errors.IsNotFound(err)
319 }).Should(BeTrue())
320 })
321
322 It("Should fall back to auto-detection when no discovery image is specified", func() {
323 By("Creating the HSMDevice")
324 Expect(k8sClient.Create(ctx, hsmDevice)).To(Succeed())
325
326 By("Reconciling the HSMDevice without DiscoveryImage set")
327 reconciler := &DiscoveryDaemonSetReconciler{
328 Client: k8sClient,
329 Scheme: k8sClient.Scheme(),
330 ImageResolver: config.NewImageResolver(k8sClient),
331 // DiscoveryImage intentionally not set to test fallback
332 }
333
334 _, err := reconciler.Reconcile(ctx, ctrl.Request{
335 NamespacedName: types.NamespacedName{
336 Name: hsmDeviceName,
337 Namespace: hsmDeviceNamespace,
338 },
339 })
340 Expect(err).NotTo(HaveOccurred())
341
342 By("Checking that DaemonSet uses default image from auto-detection")
343 daemonSetName := fmt.Sprintf("%s-discovery", hsmDeviceName)
344 daemonSet := &appsv1.DaemonSet{}
345 Eventually(func() string {
346 _ = k8sClient.Get(ctx, types.NamespacedName{
347 Name: daemonSetName,
348 Namespace: hsmDeviceNamespace,
349 }, daemonSet)
350 if len(daemonSet.Spec.Template.Spec.Containers) > 0 {
351 return daemonSet.Spec.Template.Spec.Containers[0].Image
352 }
353 return ""
354 }).Should(Equal("ghcr.io/evanjarrett/hsm-secrets-operator:latest"))
355 })
356 })
357
358 Describe("findDevicesForDaemonSet", func() {
359 var reconciler *DiscoveryDaemonSetReconciler
360
361 BeforeEach(func() {
362 reconciler = &DiscoveryDaemonSetReconciler{
363 Client: k8sClient,
364 Scheme: k8sClient.Scheme(),
365 }
366 })
367
368 It("Should return reconcile request for discovery DaemonSet", func() {
369 daemonSet := &appsv1.DaemonSet{
370 ObjectMeta: metav1.ObjectMeta{
371 Name: "test-device-discovery",
372 Namespace: "test-namespace",
373 Labels: map[string]string{
374 "app.kubernetes.io/component": "discovery",
375 "hsm.j5t.io/device": "test-device",
376 },
377 },
378 }
379
380 ctx := context.Background()
381 requests := reconciler.findDevicesForDaemonSet(ctx, daemonSet)
382
383 Expect(requests).To(HaveLen(1))
384 Expect(requests[0].Name).To(Equal("test-device"))
385 Expect(requests[0].Namespace).To(Equal("test-namespace"))
386 })
387
388 It("Should return no requests for non-discovery DaemonSet", func() {
389 daemonSet := &appsv1.DaemonSet{
390 ObjectMeta: metav1.ObjectMeta{
391 Name: "some-other-daemonset",
392 Namespace: "test-namespace",
393 Labels: map[string]string{
394 "app.kubernetes.io/component": "other",
395 "hsm.j5t.io/device": "test-device",
396 },
397 },
398 }
399
400 ctx := context.Background()
401 requests := reconciler.findDevicesForDaemonSet(ctx, daemonSet)
402
403 Expect(requests).To(BeEmpty())
404 })
405
406 It("Should return no requests for DaemonSet without device label", func() {
407 daemonSet := &appsv1.DaemonSet{
408 ObjectMeta: metav1.ObjectMeta{
409 Name: "test-device-discovery",
410 Namespace: "test-namespace",
411 Labels: map[string]string{
412 "app.kubernetes.io/component": "discovery",
413 },
414 },
415 }
416
417 ctx := context.Background()
418 requests := reconciler.findDevicesForDaemonSet(ctx, daemonSet)
419
420 Expect(requests).To(BeEmpty())
421 })
422
423 It("Should return no requests for DaemonSet without component label", func() {
424 daemonSet := &appsv1.DaemonSet{
425 ObjectMeta: metav1.ObjectMeta{
426 Name: "test-device-discovery",
427 Namespace: "test-namespace",
428 Labels: map[string]string{
429 "hsm.j5t.io/device": "test-device",
430 },
431 },
432 }
433
434 ctx := context.Background()
435 requests := reconciler.findDevicesForDaemonSet(ctx, daemonSet)
436
437 Expect(requests).To(BeEmpty())
438 })
439
440 It("Should return no requests for non-DaemonSet object", func() {
441 deployment := &appsv1.Deployment{
442 ObjectMeta: metav1.ObjectMeta{
443 Name: "test-deployment",
444 Namespace: "test-namespace",
445 },
446 }
447
448 ctx := context.Background()
449 requests := reconciler.findDevicesForDaemonSet(ctx, deployment)
450
451 Expect(requests).To(BeEmpty())
452 })
453 })
454
455 Describe("findDevicesForHSMPool", func() {
456 var reconciler *DiscoveryDaemonSetReconciler
457
458 BeforeEach(func() {
459 reconciler = &DiscoveryDaemonSetReconciler{
460 Client: k8sClient,
461 Scheme: k8sClient.Scheme(),
462 }
463 })
464
465 It("Should return reconcile request for pool HSMPool", func() {
466 hsmPool := &hsmv1alpha1.HSMPool{
467 ObjectMeta: metav1.ObjectMeta{
468 Name: "test-device-pool",
469 Namespace: "test-namespace",
470 Labels: map[string]string{
471 "app.kubernetes.io/component": "pool",
472 "hsm.j5t.io/device": "test-device",
473 },
474 },
475 }
476
477 ctx := context.Background()
478 requests := reconciler.findDevicesForHSMPool(ctx, hsmPool)
479
480 Expect(requests).To(HaveLen(1))
481 Expect(requests[0].Name).To(Equal("test-device"))
482 Expect(requests[0].Namespace).To(Equal("test-namespace"))
483 })
484
485 It("Should return no requests for non-pool HSMPool", func() {
486 hsmPool := &hsmv1alpha1.HSMPool{
487 ObjectMeta: metav1.ObjectMeta{
488 Name: "some-other-pool",
489 Namespace: "test-namespace",
490 Labels: map[string]string{
491 "app.kubernetes.io/component": "other",
492 "hsm.j5t.io/device": "test-device",
493 },
494 },
495 }
496
497 ctx := context.Background()
498 requests := reconciler.findDevicesForHSMPool(ctx, hsmPool)
499
500 Expect(requests).To(BeEmpty())
501 })
502
503 It("Should return no requests for HSMPool without device label", func() {
504 hsmPool := &hsmv1alpha1.HSMPool{
505 ObjectMeta: metav1.ObjectMeta{
506 Name: "test-device-pool",
507 Namespace: "test-namespace",
508 Labels: map[string]string{
509 "app.kubernetes.io/component": "pool",
510 },
511 },
512 }
513
514 ctx := context.Background()
515 requests := reconciler.findDevicesForHSMPool(ctx, hsmPool)
516
517 Expect(requests).To(BeEmpty())
518 })
519
520 It("Should return no requests for HSMPool without component label", func() {
521 hsmPool := &hsmv1alpha1.HSMPool{
522 ObjectMeta: metav1.ObjectMeta{
523 Name: "test-device-pool",
524 Namespace: "test-namespace",
525 Labels: map[string]string{
526 "hsm.j5t.io/device": "test-device",
527 },
528 },
529 }
530
531 ctx := context.Background()
532 requests := reconciler.findDevicesForHSMPool(ctx, hsmPool)
533
534 Expect(requests).To(BeEmpty())
535 })
536
537 It("Should return no requests for non-HSMPool object", func() {
538 deployment := &appsv1.Deployment{
539 ObjectMeta: metav1.ObjectMeta{
540 Name: "test-deployment",
541 Namespace: "test-namespace",
542 },
543 }
544
545 ctx := context.Background()
546 requests := reconciler.findDevicesForHSMPool(ctx, deployment)
547
548 Expect(requests).To(BeEmpty())
549 })
550 })
551})