A Kubernetes operator that bridges Hardware Security Module (HSM) data storage with Kubernetes Secrets, providing true secret portability th
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 551 lines 17 kB view raw
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})