From 35ada602d15990b4b1a3b3bd850621e08733f543 Mon Sep 17 00:00:00 2001 From: Thomas Maas Date: Fri, 10 Jul 2026 13:39:18 +0200 Subject: [PATCH 1/2] fix: watch Secrets to clear transient APIKey failures The APIKey status controller sets SecretNotFound when the referenced Secret does not exist yet. However, no watched resource changes when the Secret is subsequently created, so the APIKey stays stuck in Failed state permanently. Add a Secret watch so creation/updates trigger re-reconciliation. The existing calculateFailedCondition logic already re-evaluates and clears the failure when the Secret appears. Fixes #78 Signed-off-by: Thomas Maas --- internal/controller/apikey_status_controller.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/internal/controller/apikey_status_controller.go b/internal/controller/apikey_status_controller.go index b1bfa0f..d011e9a 100644 --- a/internal/controller/apikey_status_controller.go +++ b/internal/controller/apikey_status_controller.go @@ -53,7 +53,7 @@ type APIKeyStatusReconciler struct { // +kubebuilder:rbac:groups=devportal.kuadrant.io,resources=apiproducts,verbs=get;list;watch // +kubebuilder:rbac:groups=devportal.kuadrant.io,resources=apikeyapprovals,verbs=get;list;watch // +kubebuilder:rbac:groups=devportal.kuadrant.io,resources=apikeyrequests,verbs=get;list;watch -// +kubebuilder:rbac:groups="",resources=secrets,verbs=get +// +kubebuilder:rbac:groups="",resources=secrets,verbs=get;list;watch // +kubebuilder:rbac:groups=gateway.networking.k8s.io,resources=httproutes,verbs=get;list;watch // +kubebuilder:rbac:groups=kuadrant.io,resources=kuadrants,verbs=get;list;watch @@ -557,6 +557,7 @@ func (r *APIKeyStatusReconciler) SetupWithManager(mgr ctrl.Manager) error { Watches(&devportalv1alpha1.APIKeyRequest{}, handler.EnqueueRequestsFromMapFunc(r.enqueueClass)). Watches(&devportalv1alpha1.APIKeyApproval{}, handler.EnqueueRequestsFromMapFunc(r.enqueueClass)). Watches(&gwapiv1.HTTPRoute{}, handler.EnqueueRequestsFromMapFunc(r.enqueueClass)). + Watches(&corev1.Secret{}, handler.EnqueueRequestsFromMapFunc(r.enqueueClass)). Named("apikey-status"). Complete(r) } From 63a85b5936dfc8834eaf16edb864ad9c33729c7c Mon Sep 17 00:00:00 2001 From: Thomas Maas Date: Fri, 10 Jul 2026 13:45:57 +0200 Subject: [PATCH 2/2] test: add regression test for Secret-after-APIKey recovery Verify that an APIKey in SecretNotFound state recovers when the referenced Secret is subsequently created. This is the scenario triggered by the console-plugin's ownerReference flow (PR #541) where the APIKey is created before its Secret. Ref #78 Signed-off-by: Thomas Maas --- .../apikey_status_controller_test.go | 85 +++++++++++++++++++ 1 file changed, 85 insertions(+) diff --git a/internal/controller/apikey_status_controller_test.go b/internal/controller/apikey_status_controller_test.go index 4169540..06aa279 100644 --- a/internal/controller/apikey_status_controller_test.go +++ b/internal/controller/apikey_status_controller_test.go @@ -313,6 +313,91 @@ var _ = Describe("APIKey Status Controller", func() { }, time.Second*10, time.Millisecond*250).Should(Succeed()) }) + // Regression test for https://github.com/Kuadrant/developer-portal-controller/issues/78 + // + // When the console-plugin creates an APIKey before its Secret (to set + // an ownerReference for garbage collection), the status controller + // sets SecretNotFound on the first reconciliation. Without a Secret + // watch, no subsequent event triggers re-reconciliation, so the + // APIKey stays stuck in Failed permanently even after the Secret + // appears. This test verifies that creating the Secret clears the + // failure. + It("should clear Failed condition when Secret is created after the APIKey", func() { + controllerReconciler := &APIKeyStatusReconciler{ + Client: k8sClient, + Scheme: k8sClient.Scheme(), + } + + secretName := "late-arriving-secret" + + By("Creating an APIKey that references a Secret that does not exist yet") + apiKeyObj := &devportalv1alpha1.APIKey{ + ObjectMeta: metav1.ObjectMeta{ + Name: "apikey-late-secret", + Namespace: consumerNamespace, + }, + Spec: devportalv1alpha1.APIKeySpec{ + APIProductRef: devportalv1alpha1.APIProductReference{ + Name: apiProductName, + Namespace: apiProductNamespace, + }, + SecretRef: corev1.LocalObjectReference{ + Name: secretName, + }, + PlanTier: "premium", + UseCase: "Testing secret recovery", + RequestedBy: devportalv1alpha1.RequestedBy{ + UserID: "test-user", + Email: "test@example.com", + }, + }, + } + Expect(k8sClient.Create(ctx, apiKeyObj)).To(Succeed()) + + By("Running reconciliation — should set SecretNotFound") + _, err := controllerReconciler.Reconcile(ctx, reconcile.Request{}) + Expect(err).NotTo(HaveOccurred()) + + updatedAPIKey := &devportalv1alpha1.APIKey{} + Eventually(func(g Gomega) { + err := k8sClient.Get(ctx, types.NamespacedName{ + Name: "apikey-late-secret", + Namespace: consumerNamespace, + }, updatedAPIKey) + g.Expect(err).NotTo(HaveOccurred()) + failedCondition := meta.FindStatusCondition(updatedAPIKey.Status.Conditions, devportalv1alpha1.APIKeyConditionFailed) + g.Expect(failedCondition).NotTo(BeNil()) + g.Expect(failedCondition.Reason).To(Equal("SecretNotFound")) + }, time.Second*10, time.Millisecond*250).Should(Succeed()) + + By("Creating the Secret that the APIKey references") + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: secretName, + Namespace: consumerNamespace, + }, + Type: corev1.SecretTypeOpaque, + Data: map[string][]byte{ + "api_key": []byte("test-key-value"), + }, + } + Expect(k8sClient.Create(ctx, secret)).To(Succeed()) + + By("Running reconciliation — should clear the Failed condition") + _, err = controllerReconciler.Reconcile(ctx, reconcile.Request{}) + Expect(err).NotTo(HaveOccurred()) + + Eventually(func(g Gomega) { + err := k8sClient.Get(ctx, types.NamespacedName{ + Name: "apikey-late-secret", + Namespace: consumerNamespace, + }, updatedAPIKey) + g.Expect(err).NotTo(HaveOccurred()) + failedCondition := meta.FindStatusCondition(updatedAPIKey.Status.Conditions, devportalv1alpha1.APIKeyConditionFailed) + g.Expect(failedCondition).To(BeNil(), "Failed condition should be cleared after Secret is created") + }, time.Second*10, time.Millisecond*250).Should(Succeed()) + }) + It("should set Pending condition when no approval exists", func() { controllerReconciler := &APIKeyStatusReconciler{ Client: k8sClient,