From d81128cb01a76c1ef1168077e04cf08687175e90 Mon Sep 17 00:00:00 2001 From: Jakob Bergmeier Date: Thu, 16 Jul 2026 16:46:12 +0200 Subject: [PATCH] feat: Support reconcile operation annotation Signed-off-by: Jakob Bergmeier --- pkg/serviceprovider/apireconciler.go | 30 +++++++++++- pkg/serviceprovider/apireconciler_test.go | 58 +++++++++++++++++++++++ 2 files changed, 86 insertions(+), 2 deletions(-) diff --git a/pkg/serviceprovider/apireconciler.go b/pkg/serviceprovider/apireconciler.go index 72a0511..c7fd726 100644 --- a/pkg/serviceprovider/apireconciler.go +++ b/pkg/serviceprovider/apireconciler.go @@ -166,8 +166,11 @@ func (r *APIReconciler[T, C]) Reconcile(ctx context.Context, req ctrl.Request) ( if err := r.onboardingCluster.Client().Get(ctx, req.NamespacedName, obj); err != nil { return ctrl.Result{}, client.IgnoreNotFound(err) } - // Skip reconciliation if annotation is set - if obj.GetAnnotations()[apiconst.OperationAnnotation] == apiconst.OperationAnnotationValueIgnore { + skip, err := r.handleOperationAnnotation(ctx, obj) + if err != nil { + return ctrl.Result{}, err + } + if skip { l.Info("Skipping resource due to ignore operation annotation") return ctrl.Result{}, nil } @@ -221,6 +224,29 @@ func (r *APIReconciler[T, C]) Reconcile(ctx context.Context, req ctrl.Request) ( }, nil } +func (r *APIReconciler[T, C]) handleOperationAnnotation(ctx context.Context, obj T) (bool, error) { + annotations := obj.GetAnnotations() + if annotations == nil { + return false, nil + } + op, ok := annotations[apiconst.OperationAnnotation] + if !ok { + return false, nil + } + l := logf.FromContext(ctx) + switch op { + case apiconst.OperationAnnotationValueIgnore: + return true, nil + case apiconst.OperationAnnotationValueReconcile: + l.Info("Reconciliation requested via annotation. Removing annotation and proceeding with reconciliation") + if err := controllerutil2.EnsureAnnotation(ctx, r.onboardingCluster.Client(), obj, apiconst.OperationAnnotation, "", true, controllerutil2.DELETE); err != nil { + l.Error(err, "Failed to remove operation annotation") + return false, err + } + } + return false, nil +} + func (r *APIReconciler[T, PC]) updateStatus(ctx context.Context, newObj T, oldObj T) error { if equality.Semantic.DeepEqual(oldObj.GetStatus(), newObj.GetStatus()) { return nil diff --git a/pkg/serviceprovider/apireconciler_test.go b/pkg/serviceprovider/apireconciler_test.go index eca4e57..db57f58 100644 --- a/pkg/serviceprovider/apireconciler_test.go +++ b/pkg/serviceprovider/apireconciler_test.go @@ -377,6 +377,27 @@ func assertStatusUpdate(t *testing.T, c client.Client, req ctrl.Request, wantSta assert.Equal(t, wantStatusPhase, status.Phase) } +func assertReconcileAnnotationRemoved(t *testing.T, c client.Client, req ctrl.Request) { + t.Helper() + obj := &fakeApiImpl{} + obj.SetName(req.Name) + obj.SetNamespace(req.Namespace) + require.NoError(t, c.Get(context.Background(), client.ObjectKeyFromObject(obj), obj)) + _, hasAnnotation := obj.GetAnnotations()[apiconst.OperationAnnotation] + assert.False(t, hasAnnotation, "Operation annotation should have been removed") +} + +func assertIgnoreAnnotationPersisted(t *testing.T, c client.Client, req ctrl.Request) { + t.Helper() + obj := &fakeApiImpl{} + obj.SetName(req.Name) + obj.SetNamespace(req.Namespace) + require.NoError(t, c.Get(context.Background(), client.ObjectKeyFromObject(obj), obj)) + _, hasAnnotation := obj.GetAnnotations()[apiconst.OperationAnnotation] + assert.True(t, hasAnnotation, "Operation annotation should have been persisted") + assert.Equal(t, apiconst.OperationAnnotationValueIgnore, obj.GetAnnotations()[apiconst.OperationAnnotation], "Operation annotation value should be 'ignore'") +} + var _ clusteraccess.Provider = FakeClusterAccessProvider{} var _ Reconciler[*fakeApiImpl, *fakeProviderConfigImpl] = &MockServiceProviderReconciler{} @@ -698,6 +719,33 @@ func TestAPIReconciler_Reconcile_Advanced(t *testing.T) { wantReconciliation: false, wantErr: false, }, + { + name: "Operation annotation reconcile -> remove annotation and proceed with reconciliation", + apiObj: &fakeApiImpl{ + ObjectMeta: metav1.ObjectMeta{ + Name: testObjectName, + Namespace: testNamespaceName, + Annotations: map[string]string{ + apiconst.OperationAnnotation: apiconst.OperationAnnotationValueReconcile, + }, + }, + }, + req: ctrl.Request{ + NamespacedName: types.NamespacedName{ + Name: testObjectName, + Namespace: testNamespaceName, + }, + }, + providerConfig: &fakeProviderConfigImpl{ + ObjectMeta: metav1.ObjectMeta{ + Name: testObjectName, + }, + }, + want: ctrl.Result{}, + wantStatusPhase: StatusPhaseReady, + wantReconciliation: true, + wantErr: false, + }, { name: "cluster access reconciler fails -> error and status update", apiObj: &fakeApiImpl{ @@ -809,6 +857,16 @@ func TestAPIReconciler_Reconcile_Advanced(t *testing.T) { Name: testWorkloadKubeconfig, }, mockReconciler.clusterContext.WorkloadAccessSecretKey) assertStatusUpdate(t, onboardingCluster.Client(), tt.req, tt.wantStatusPhase) + + // For annotation reconcile case, verify the annotation was persisted + if tt.name == "Operation annotation ignore -> no reconciliation, no requeue" { + assertIgnoreAnnotationPersisted(t, onboardingCluster.Client(), tt.req) + } + + // For annotation reconcile case, verify the annotation was removed + if tt.name == "Operation annotation reconcile -> remove annotation and proceed with reconciliation" { + assertReconcileAnnotationRemoved(t, onboardingCluster.Client(), tt.req) + } }) } }