From ec3c959505707db50ab26bbb5ce93cb14ef543f1 Mon Sep 17 00:00:00 2001 From: Pujol Date: Wed, 5 Aug 2026 17:40:21 +0200 Subject: [PATCH] Add skip-provisioning maintenance annotation for Device controller The Device controller now supports a skip-provisioning value for the maintenance annotation. This allows devices that have been provisioned out-of-band to bypass the Provisioning phase while keeping the provisioning config in the spec for future reprovisioning. The controller handles the annotation in two phases. When a device is in Pending phase with spec.provisioning configured, the annotation causes a direct transition to Running without entering Provisioning. When a device is already in Provisioning phase, the annotation closes any active provisioning entry by setting its EndTime and transitions the device to Running. In both cases the annotation is consumed after processing and the controller emits a SkipProvisioning event. The reconcileMaintenance function was updated to preserve the annotation until it is consumed by the phase handlers. Updated the test infrastructure to support event verification. Drain and collect events from FakeRecorder into a a slice that tests can use. Signed-off-by: Pujol --- api/core/v1alpha1/groupversion_info.go | 5 + internal/controller/core/device_controller.go | 23 ++ .../controller/core/device_controller_test.go | 306 +++++++++++++----- internal/controller/core/suite_test.go | 4 +- 4 files changed, 257 insertions(+), 81 deletions(-) diff --git a/api/core/v1alpha1/groupversion_info.go b/api/core/v1alpha1/groupversion_info.go index 2bac14340..7ed00d1b6 100644 --- a/api/core/v1alpha1/groupversion_info.go +++ b/api/core/v1alpha1/groupversion_info.go @@ -108,6 +108,11 @@ const ( // device-side operations. This is useful for recovering from terminal states (e.g., Failed) // after manual intervention. DeviceMaintenanceResetPhase = "reset-phase" + // DeviceMaintenanceSkipProvisioning is for devices provisioned out-of-band. + // Transitions the device from Pending (or Provisioning) directly to Running, but only if + // spec.provisioning is defined. The annotation is removed once a phase transition is triggered by + // the annotation itself; otherwise it is ignored and left on the resource. + DeviceMaintenanceSkipProvisioning = "skip-provisioning" ) // Condition types that are used across different objects. diff --git a/internal/controller/core/device_controller.go b/internal/controller/core/device_controller.go index 9fa361d08..00138f658 100644 --- a/internal/controller/core/device_controller.go +++ b/internal/controller/core/device_controller.go @@ -137,6 +137,14 @@ func (r *DeviceReconciler) Reconcile(ctx context.Context, req ctrl.Request) (_ c return ctrl.Result{}, reconcile.TerminalError(errors.New("provider does not support provisioning")) } + if action, ok := obj.Annotations[v1alpha1.DeviceMaintenanceAnnotation]; ok && action == v1alpha1.DeviceMaintenanceSkipProvisioning { + // Skip provisioning if the device is annotated to skip provisioning. + obj.Status.Phase = v1alpha1.DevicePhaseRunning + r.Recorder.Eventf(obj, nil, "Normal", "SkipProvisioning", "Maintenance", "Device in pending phase will skip provisioning due to maintenance annotation") + delete(obj.Annotations, v1alpha1.DeviceMaintenanceAnnotation) + return ctrl.Result{}, nil + } + log.Info("Device is in pending phase, starting provisioning") conditions.Set(obj, metav1.Condition{ Type: v1alpha1.ReadyCondition, @@ -149,6 +157,17 @@ func (r *DeviceReconciler) Reconcile(ctx context.Context, req ctrl.Request) (_ c return ctrl.Result{}, nil case v1alpha1.DevicePhaseProvisioning: + if action, ok := obj.Annotations[v1alpha1.DeviceMaintenanceAnnotation]; ok && action == v1alpha1.DeviceMaintenanceSkipProvisioning { + // Close all active provisioning attempts and skip provisioning if the device is annotated accordingly. + if activeProv := obj.GetActiveProvisioning(); activeProv != nil { + activeProv.EndTime = metav1.Now() + } + obj.Status.Phase = v1alpha1.DevicePhaseRunning + r.Recorder.Eventf(obj, nil, "Normal", "SkipProvisioning", "Maintenance", "Device in provisioning phase will skip provisioning due to maintenance annotation") + delete(obj.Annotations, v1alpha1.DeviceMaintenanceAnnotation) + return ctrl.Result{}, nil + } + if obj.Spec.Provisioning == nil { log.Info("Provisioning configuration was removed, resetting device into pending phase") if activeProv := obj.GetActiveProvisioning(); activeProv != nil { @@ -429,6 +448,10 @@ func (r *DeviceReconciler) reconcileMaintenance(ctx context.Context, obj *v1alph } switch action { + case v1alpha1.DeviceMaintenanceSkipProvisioning: + // The annotation must stay present until the annotation is consumed. + return nil + case v1alpha1.DeviceMaintenanceResetPhase: // Reset phase is a soft reset that only changes the device phase to Pending without // performing any device-side operations. This is useful for recovering from terminal diff --git a/internal/controller/core/device_controller_test.go b/internal/controller/core/device_controller_test.go index bb4e5238a..7aeb5e2b6 100644 --- a/internal/controller/core/device_controller_test.go +++ b/internal/controller/core/device_controller_test.go @@ -388,86 +388,6 @@ var _ = Describe("Device Controller", func() { }).Should(Succeed()) }) - It("Should transition from Running to Provisioning once the reset-phase annotation is set", func() { - By("Creating a Device") - device := &v1alpha1.Device{ - ObjectMeta: metav1.ObjectMeta{ - Name: key.Name, - Namespace: key.Namespace, - }, - Spec: v1alpha1.DeviceSpec{ - Endpoint: v1alpha1.Endpoint{ - Address: "192.168.10.5:9339", - SecretRef: &v1alpha1.SecretReference{ - Name: name, - }, - }, - Provisioning: &v1alpha1.Provisioning{ - BootScript: v1alpha1.TemplateSource{ - Inline: new("boot nxos.bin"), - }, - Image: v1alpha1.Image{ - URL: "https://best-vendor-images.to/windows98", - Checksum: "d41d8cd98f00b204e9800998ecf8427e", - ChecksumType: v1alpha1.ChecksumTypeMD5, - }, - }, - }, - } - Expect(k8sClient.Create(ctx, device)).To(Succeed()) - - By("Verifying the device transitions to Provisioning phase") - Eventually(func(g Gomega) { - resource := &v1alpha1.Device{} - g.Expect(k8sClient.Get(ctx, key, resource)).To(Succeed()) - g.Expect(resource.Status.Phase).To(Equal(v1alpha1.DevicePhaseProvisioning)) - g.Expect(resource.Status.Conditions).To(HaveLen(3)) - g.Expect(resource.Status.Conditions[0].Type).To(Equal(v1alpha1.ReadyCondition)) - g.Expect(resource.Status.Conditions[1].Type).To(Equal(v1alpha1.PausedCondition)) - g.Expect(resource.Status.Conditions[1].Status).To(Equal(metav1.ConditionFalse)) - g.Expect(resource.Status.Conditions[2].Type).To(Equal(v1alpha1.ReachableCondition)) - g.Expect(resource.Status.Conditions[2].Status).To(Equal(metav1.ConditionUnknown)) - }).Should(Succeed()) - - By("Setting the device to Running phase") - orig := device.DeepCopy() - device.Status.Phase = v1alpha1.DevicePhaseRunning - Expect(k8sClient.Status().Patch(ctx, device, client.MergeFrom(orig))).To(Succeed()) - - By("Verifying the device transitions to Running phase") - Eventually(func(g Gomega) { - resource := &v1alpha1.Device{} - g.Expect(k8sClient.Get(ctx, key, resource)).To(Succeed()) - g.Expect(resource.Status.Phase).To(Equal(v1alpha1.DevicePhaseRunning)) - g.Expect(resource.Status.Conditions).To(HaveLen(3)) - g.Expect(resource.Status.Conditions[0].Type).To(Equal(v1alpha1.ReadyCondition)) - g.Expect(resource.Status.Conditions[1].Type).To(Equal(v1alpha1.PausedCondition)) - g.Expect(resource.Status.Conditions[1].Status).To(Equal(metav1.ConditionFalse)) - g.Expect(resource.Status.Conditions[2].Type).To(Equal(v1alpha1.ReachableCondition)) - g.Expect(resource.Status.Conditions[2].Status).To(Equal(metav1.ConditionTrue)) - }).Should(Succeed()) - - By("Adding the reset-phase annotation to the device") - Eventually(func(g Gomega) { - resource := &v1alpha1.Device{} - g.Expect(k8sClient.Get(ctx, key, resource)).To(Succeed()) - patch := resource.DeepCopy() - annotations := make(map[string]string) - annotations[v1alpha1.DeviceMaintenanceAnnotation] = v1alpha1.DeviceMaintenanceResetPhase - patch.SetAnnotations(annotations) - g.Expect(k8sClient.Patch(ctx, patch, client.MergeFrom(resource))).To(Succeed()) - }).Should(Succeed()) - - By("Verifying the device transitions to Provisioning phase and the annotation is removed") - Eventually(func(g Gomega) { - resource := &v1alpha1.Device{} - g.Expect(k8sClient.Get(ctx, key, resource)).To(Succeed()) - g.Expect(resource.Status.Phase).To(Equal(v1alpha1.DevicePhaseProvisioning)) - _, exists := resource.Annotations[v1alpha1.DeviceMaintenanceAnnotation] - g.Expect(exists).To(BeFalse(), "Maintenance annotation should be removed after processing") - }).Should(Succeed()) - }) - It("Should set Reachable=False and Ready=Unknown when the device is unreachable", func() { By("Making the provider return a connect error") testProvider.SetConnectError(errors.New("connection refused")) @@ -682,4 +602,230 @@ var _ = Describe("Device Controller", func() { }).Should(Succeed()) }) }) + + Context("When using maintenance annotations", func() { + var name string + var key client.ObjectKey + + BeforeEach(func() { + By("Creating the endpoint credentials as a Secret") + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "test-device-", + Namespace: metav1.NamespaceDefault, + }, + Data: map[string][]byte{ + corev1.BasicAuthUsernameKey: []byte("user"), + corev1.BasicAuthPasswordKey: []byte("password"), + }, + Type: corev1.SecretTypeBasicAuth, + } + Expect(k8sClient.Create(ctx, secret)).To(Succeed()) + name = secret.Name + key = client.ObjectKey{Name: name, Namespace: metav1.NamespaceDefault} + + testEvents = nil + + DeferCleanup(func() { + Expect(client.IgnoreNotFound(k8sClient.Delete(ctx, secret))).To(Succeed()) + }) + }) + + It("Should transition from Running to Provisioning once the reset-phase annotation is set", func() { + By("Creating a Device") + device := &v1alpha1.Device{ + ObjectMeta: metav1.ObjectMeta{ + Name: key.Name, + Namespace: key.Namespace, + }, + Spec: v1alpha1.DeviceSpec{ + Endpoint: v1alpha1.Endpoint{ + Address: "192.168.10.5:9339", + SecretRef: &v1alpha1.SecretReference{ + Name: name, + }, + }, + Provisioning: &v1alpha1.Provisioning{ + BootScript: v1alpha1.TemplateSource{ + Inline: new("boot nxos.bin"), + }, + Image: v1alpha1.Image{ + URL: "https://best-vendor-images.to/windows98", + Checksum: "d41d8cd98f00b204e9800998ecf8427e", + ChecksumType: v1alpha1.ChecksumTypeMD5, + }, + }, + }, + } + Expect(k8sClient.Create(ctx, device)).To(Succeed()) + + By("Verifying the device transitions to Provisioning phase") + Eventually(func(g Gomega) { + resource := &v1alpha1.Device{} + g.Expect(k8sClient.Get(ctx, key, resource)).To(Succeed()) + g.Expect(resource.Status.Phase).To(Equal(v1alpha1.DevicePhaseProvisioning)) + g.Expect(resource.Status.Conditions).To(HaveLen(3)) + g.Expect(resource.Status.Conditions[0].Type).To(Equal(v1alpha1.ReadyCondition)) + g.Expect(resource.Status.Conditions[1].Type).To(Equal(v1alpha1.PausedCondition)) + g.Expect(resource.Status.Conditions[1].Status).To(Equal(metav1.ConditionFalse)) + g.Expect(resource.Status.Conditions[2].Type).To(Equal(v1alpha1.ReachableCondition)) + g.Expect(resource.Status.Conditions[2].Status).To(Equal(metav1.ConditionUnknown)) + }).Should(Succeed()) + + By("Setting the device to Running phase") + orig := device.DeepCopy() + device.Status.Phase = v1alpha1.DevicePhaseRunning + Expect(k8sClient.Status().Patch(ctx, device, client.MergeFrom(orig))).To(Succeed()) + + By("Verifying the device transitions to Running phase") + Eventually(func(g Gomega) { + resource := &v1alpha1.Device{} + g.Expect(k8sClient.Get(ctx, key, resource)).To(Succeed()) + g.Expect(resource.Status.Phase).To(Equal(v1alpha1.DevicePhaseRunning)) + g.Expect(resource.Status.Conditions).To(HaveLen(3)) + g.Expect(resource.Status.Conditions[0].Type).To(Equal(v1alpha1.ReadyCondition)) + g.Expect(resource.Status.Conditions[1].Type).To(Equal(v1alpha1.PausedCondition)) + g.Expect(resource.Status.Conditions[1].Status).To(Equal(metav1.ConditionFalse)) + g.Expect(resource.Status.Conditions[2].Type).To(Equal(v1alpha1.ReachableCondition)) + g.Expect(resource.Status.Conditions[2].Status).To(Equal(metav1.ConditionTrue)) + }).Should(Succeed()) + + By("Adding the reset-phase annotation to the device") + Eventually(func(g Gomega) { + resource := &v1alpha1.Device{} + g.Expect(k8sClient.Get(ctx, key, resource)).To(Succeed()) + patch := resource.DeepCopy() + annotations := make(map[string]string) + annotations[v1alpha1.DeviceMaintenanceAnnotation] = v1alpha1.DeviceMaintenanceResetPhase + patch.SetAnnotations(annotations) + g.Expect(k8sClient.Patch(ctx, patch, client.MergeFrom(resource))).To(Succeed()) + }).Should(Succeed()) + + By("Verifying the device transitions to Provisioning phase and the annotation is removed") + Eventually(func(g Gomega) { + resource := &v1alpha1.Device{} + g.Expect(k8sClient.Get(ctx, key, resource)).To(Succeed()) + g.Expect(resource.Status.Phase).To(Equal(v1alpha1.DevicePhaseProvisioning)) + _, exists := resource.Annotations[v1alpha1.DeviceMaintenanceAnnotation] + g.Expect(exists).To(BeFalse(), "Maintenance annotation should be removed after processing") + }).Should(Succeed()) + }) + + It("Should skip provisioning and transition from Pending to Running when skip-provisioning annotation is set", func() { + By("Creating a Device with provisioning configured and skip-provisioning annotation") + device := &v1alpha1.Device{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "test-device-", + Namespace: metav1.NamespaceDefault, + Annotations: map[string]string{ + v1alpha1.DeviceMaintenanceAnnotation: v1alpha1.DeviceMaintenanceSkipProvisioning, + }, + }, + Spec: v1alpha1.DeviceSpec{ + Endpoint: v1alpha1.Endpoint{ + Address: "192.168.10.6:9339", + SecretRef: &v1alpha1.SecretReference{ + Name: name, + }, + }, + Provisioning: &v1alpha1.Provisioning{ + BootScript: v1alpha1.TemplateSource{ + Inline: new("boot nxos.bin"), + }, + Image: v1alpha1.Image{ + URL: "https://example.com/image.bin", + Checksum: "d41d8cd98f00b204e9800998ecf8427e", + ChecksumType: v1alpha1.ChecksumTypeMD5, + }, + }, + }, + } + Expect(k8sClient.Create(ctx, device)).To(Succeed()) + key := client.ObjectKeyFromObject(device) + + By("Verifying the device reaches Running phase with annotation removed and SkipProvisioning event emitted") + Eventually(func(g Gomega) { + resource := &v1alpha1.Device{} + g.Expect(k8sClient.Get(ctx, key, resource)).To(Succeed()) + g.Expect(resource.Status.Phase).To(Equal(v1alpha1.DevicePhaseRunning)) + _, exists := resource.Annotations[v1alpha1.DeviceMaintenanceAnnotation] + g.Expect(exists).To(BeFalse(), "skip-provisioning annotation should be removed after processing") + g.Expect(testEvents).To(ContainElement(ContainSubstring("SkipProvisioning"))) + g.Expect(testEvents).NotTo(ContainElement(ContainSubstring("ProvisioningStarted"))) + }).Should(Succeed()) + }) + + It("Should close active provisioning entry and transition to Running when skip-provisioning annotation is set during Provisioning phase", func() { + By("Creating a Device with provisioning configured") + device := &v1alpha1.Device{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "test-device-", + Namespace: metav1.NamespaceDefault, + }, + Spec: v1alpha1.DeviceSpec{ + Endpoint: v1alpha1.Endpoint{ + Address: "192.168.10.7:9339", + SecretRef: &v1alpha1.SecretReference{ + Name: name, + }, + }, + Provisioning: &v1alpha1.Provisioning{ + BootScript: v1alpha1.TemplateSource{ + Inline: new("boot nxos.bin"), + }, + Image: v1alpha1.Image{ + URL: "https://example.com/image.bin", + Checksum: "d41d8cd98f00b204e9800998ecf8427e", + ChecksumType: v1alpha1.ChecksumTypeMD5, + }, + }, + }, + } + Expect(k8sClient.Create(ctx, device)).To(Succeed()) + key := client.ObjectKeyFromObject(device) + + By("Waiting for the device to enter Provisioning phase") + Eventually(func(g Gomega) { + resource := &v1alpha1.Device{} + g.Expect(k8sClient.Get(ctx, key, resource)).To(Succeed()) + g.Expect(resource.Status.Phase).To(Equal(v1alpha1.DevicePhaseProvisioning)) + }).Should(Succeed()) + + By("Injecting an active provisioning entry (simulating provisioning agent)") + Eventually(func(g Gomega) { + resource := &v1alpha1.Device{} + g.Expect(k8sClient.Get(ctx, key, resource)).To(Succeed()) + patch := resource.DeepCopy() + patch.Status.Provisioning = []v1alpha1.ProvisioningInfo{{ + Token: "test-token", + StartTime: metav1.NewTime(time.Now().Add(-5 * time.Minute)), + }} + g.Expect(k8sClient.Status().Patch(ctx, patch, client.MergeFrom(resource))).To(Succeed()) + }).Should(Succeed()) + + By("Adding the skip-provisioning annotation to the device") + Eventually(func(g Gomega) { + resource := &v1alpha1.Device{} + g.Expect(k8sClient.Get(ctx, key, resource)).To(Succeed()) + patch := resource.DeepCopy() + if patch.Annotations == nil { + patch.Annotations = make(map[string]string) + } + patch.Annotations[v1alpha1.DeviceMaintenanceAnnotation] = v1alpha1.DeviceMaintenanceSkipProvisioning + g.Expect(k8sClient.Patch(ctx, patch, client.MergeFrom(resource))).To(Succeed()) + }).Should(Succeed()) + + By("Verifying the device transitions to Running phase with the provisioning entry closed") + Eventually(func(g Gomega) { + resource := &v1alpha1.Device{} + g.Expect(k8sClient.Get(ctx, key, resource)).To(Succeed()) + g.Expect(resource.Status.Phase).To(Equal(v1alpha1.DevicePhaseRunning)) + g.Expect(resource.Status.Provisioning).To(HaveLen(1)) + g.Expect(resource.Status.Provisioning[0].EndTime.IsZero()).To(BeFalse(), "EndTime should be set on closed provisioning entry") + _, exists := resource.Annotations[v1alpha1.DeviceMaintenanceAnnotation] + g.Expect(exists).To(BeFalse(), "skip-provisioning annotation should be removed after processing") + g.Expect(testEvents).To(ContainElement(ContainSubstring("SkipProvisioning"))) + }).Should(Succeed()) + }) + }) }) diff --git a/internal/controller/core/suite_test.go b/internal/controller/core/suite_test.go index 051a89864..0877267d3 100644 --- a/internal/controller/core/suite_test.go +++ b/internal/controller/core/suite_test.go @@ -49,7 +49,8 @@ var ( k8sManager ctrl.Manager testProvider = NewProvider() testLocker *resourcelock.ResourceLocker - + // testEvents is a slice that stores events recorded during the tests. It is used to verify that the expected events are generated by the controllers. + testEvents []string lastRebootTime = time.Date(2025, time.January, 1, 0, 0, 0, 0, time.UTC) ) @@ -100,6 +101,7 @@ var _ = BeforeSuite(func() { go func() { for event := range recorder.Events { GinkgoLogr.Info("Event", "event", event) + testEvents = append(testEvents, event) } }()