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) } }()